From 981b44e418abc36162bd9c329b3d2b787eacb5bb Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Sun, 27 Sep 2026 12:37:06 -0400 Subject: [PATCH 01/32] Lead the CLI help with the v5 workflow List scan, vex, vendor and list first, then the agent-mode commands (get, apply, setup, rollback, remove, repair), and add a short "Typical workflow" footer to the root help. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/src/lib.rs | 51 +++++++++++-------- .../tests/help_text_hygiene.rs | 39 +++++++++++--- 2 files changed, 62 insertions(+), 28 deletions(-) diff --git a/crates/socket-patch-cli/src/lib.rs b/crates/socket-patch-cli/src/lib.rs index da5533f0..c6437920 100644 --- a/crates/socket-patch-cli/src/lib.rs +++ b/crates/socket-patch-cli/src/lib.rs @@ -23,9 +23,15 @@ use clap::{Parser, Subcommand}; #[derive(Parser)] #[command( name = "socket-patch", - about = "CLI tool for applying security patches to dependencies", + about = "Patch vulnerable dependencies with Socket's security patches", version, - propagate_version = true + propagate_version = true, + after_help = "Typical workflow:\n \ + socket-patch scan Patch dependencies (rewrites lockfiles to Socket-hosted patched packages)\n \ + socket-patch vex Emit an OpenVEX document for your vulnerability scanner\n \ + socket-patch vendor Eject the patches into .socket/vendor/ for offline installs\n \ + socket-patch list Show the patches in this project\n\n\ + get, apply, setup, rollback, remove and repair are the older agent-mode commands." )] pub struct Cli { #[command(subcommand)] @@ -50,14 +56,12 @@ pub struct Cli { #[derive(Subcommand)] pub enum Commands { - /// Scan installed packages for available security patches + /// Find patches for installed packages and apply them by rewriting + /// lockfiles to Socket-hosted patched packages Scan(commands::scan::ScanArgs), - /// Apply security patches to dependencies - Apply(commands::apply::ApplyArgs), - - /// Generate an OpenVEX 0.2.0 attestation describing the - /// vulnerabilities mitigated by the applied patches. + /// Generate an OpenVEX 0.2.0 document for the vulnerabilities the + /// project's patches fix Vex(commands::vex::VexArgs), /// Eject patched dependencies into committable `.socket/vendor/` and @@ -67,29 +71,34 @@ pub enum Commands { /// Socket API needed. Vendor(commands::vendor::VendorArgs), - /// Wire install hooks (npm, Python, Bundler, Composer) that re-apply - /// patches after install - Setup(commands::setup::SetupArgs), - - /// Roll back patches to restore original files - Rollback(commands::rollback::RollbackArgs), + /// List the patches in this project: hosted and vendored lockfile + /// references plus any agent-mode manifest entries + List(commands::list::ListArgs), - /// Get security patches from the Socket API and apply them + /// Agent mode: get a patch from the Socket API and apply it #[command(visible_alias = "download")] Get(commands::get::GetArgs), - /// List all patches in the local manifest - List(commands::list::ListArgs), + /// Agent mode: apply the patches in `.socket/manifest.json` in place + Apply(commands::apply::ApplyArgs), + + /// Agent mode: wire install hooks (npm, Python, Bundler, Composer) that + /// re-apply patches after install + Setup(commands::setup::SetupArgs), + + /// Undo patches: restore original files and unwind hosted or vendored + /// lockfile wiring + Rollback(commands::rollback::RollbackArgs), - /// Remove a patch from the manifest by PURL or UUID (rolls back files first) + /// Agent mode: remove a patch from the manifest by PURL or UUID (rolls + /// back files first) Remove(commands::remove::RemoveArgs), - /// Download missing patch artifacts and clean up unused ones + /// Agent mode: download missing patch artifacts and clean up unused ones /// /// Restores missing blobs and diff/package archives, rebuilds missing /// or corrupt vendored artifacts, then deletes the artifacts nothing - /// references. It needs no scan; for the combined workflow (discover, - /// apply, clean up) use `scan --sync --json --yes`. + /// references. #[command(visible_alias = "gc")] Repair(commands::repair::RepairArgs), diff --git a/crates/socket-patch-cli/tests/help_text_hygiene.rs b/crates/socket-patch-cli/tests/help_text_hygiene.rs index 455ac4b3..9b2be79a 100644 --- a/crates/socket-patch-cli/tests/help_text_hygiene.rs +++ b/crates/socket-patch-cli/tests/help_text_hygiene.rs @@ -147,16 +147,43 @@ fn vex_product_list_renders_one_item_per_line() { fn root_command_list_uses_the_verb_form() { let text = long_help(&[]); assert!( - text.contains("Roll back patches to restore original files"), + text.contains("Undo patches: restore original files and unwind hosted or vendored lockfile wiring"), "{text}" ); assert!(!text.contains("Rollback patches"), "{text}"); assert!( - text.contains("Wire install hooks (npm, Python, Bundler, Composer)"), + text.contains("Agent mode: wire install hooks (npm, Python, Bundler, Composer)"), "{text}" ); } +/// v5 leads with scan, vex, vendor and list; the agent-mode commands follow. +#[test] +fn root_command_list_leads_with_the_v5_workflow() { + let text = long_help(&[]); + let order: Vec<&str> = text + .lines() + .filter_map(|l| l.strip_prefix(" ")) + .filter_map(|l| l.split_whitespace().next()) + .filter(|w| { + [ + "scan", "vex", "vendor", "list", "get", "apply", "setup", "rollback", "remove", + "repair", + ] + .contains(w) + }) + .collect(); + assert_eq!( + &order[..10], + [ + "scan", "vex", "vendor", "list", "get", "apply", "setup", "rollback", "remove", + "repair" + ], + "{text}" + ); + assert!(text.contains("Typical workflow:"), "{text}"); +} + #[test] fn vendor_and_repair_summaries_read_as_one_line() { let text = long_help(&[]); @@ -167,17 +194,15 @@ fn vendor_and_repair_summaries_read_as_one_line() { ); assert!( text.lines().any(|l| l - == " repair Download missing patch artifacts and clean up unused ones [aliases: gc]"), + == " repair Agent mode: download missing patch artifacts and clean up unused ones [aliases: gc]"), "{text}" ); let repair = long_help(&["repair"]); assert!( repair.starts_with( - "Download missing patch artifacts and clean up unused ones\n\n\ + "Agent mode: download missing patch artifacts and clean up unused ones\n\n\ Restores missing blobs and diff/package archives, rebuilds missing or corrupt \ - vendored artifacts, then deletes the artifacts nothing references. It needs no \ - scan; for the combined workflow (discover, apply, clean up) use \ - `scan --sync --json --yes`.\n" + vendored artifacts, then deletes the artifacts nothing references.\n" ), "{repair}" ); From d891d3b3d49ef00d63992b3967188964c66bbccb Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Sun, 27 Sep 2026 12:37:06 -0400 Subject: [PATCH 02/32] Prefer the newest merged patch when choosing one per package A merged patch folds several advisories into one blob and is the package's cumulative fix, so the newest one the user can download now wins outright. Packages with no merged patch keep the old order: highest severity, then newest. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/src/commands/get.rs | 22 +-- crates/socket-patch-core/src/api/ranking.rs | 198 ++++++++++---------- 2 files changed, 109 insertions(+), 111 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 86e0dbbb..ca54e1cb 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -4295,30 +4295,28 @@ mod tests { } #[test] - fn select_prefers_a_higher_severity_patch_over_the_merged_one() { - // The exception. A merged patch must not shadow a worse - // vulnerability: `z_critical` addresses a CRITICAL the merged patch - // does not cover, so it wins despite being older, single-advisory, - // and last by uuid. + fn select_prefers_the_merged_patch_over_a_higher_severity_one() { + // A merged patch is the cumulative fix, so it wins even against a + // newer single-advisory CRITICAL. let patches = vec![ - mk_patch_multi( - "a_merged", + mk_patch_sev( + "a_critical", "pkg:npm/foo@1.0", "free", "2026-06-01", - &["high", "high"], + "critical", ), - mk_patch_sev( - "z_critical", + mk_patch_multi( + "z_merged", "pkg:npm/foo@1.0", "free", "2020-01-01", - "critical", + &["high", "high"], ), ]; let out = select_patches(&patches, true, &human_args()).expect("ok"); assert_eq!(out.len(), 1); - assert_eq!(out[0].uuid, "z_critical"); + assert_eq!(out[0].uuid, "z_merged"); } #[test] diff --git a/crates/socket-patch-core/src/api/ranking.rs b/crates/socket-patch-core/src/api/ranking.rs index 71ce8b55..0e828ff7 100644 --- a/crates/socket-patch-core/src/api/ranking.rs +++ b/crates/socket-patch-core/src/api/ranking.rs @@ -10,38 +10,21 @@ //! //! **The order, best first:** //! -//! 1. **Severity** — critical > high > medium/moderate > low > unknown, -//! taken as the worst severity across everything the patch fixes. -//! 2. **Merge state** — a patch that remediates *more* advisories in one -//! blob leads. See [`merged_coverage`] for how this is inferred. -//! 3. **Patch publish date**, most recent first. This is the date *the -//! patch* was published, never the date the upstream package version -//! was released — a 2020 package routinely carries a patch published -//! last week, and two patches for one package have two different dates. -//! See [`crate::api::types::PatchResponse::published_at`]. -//! 4. Paid tier, then UUID — pure tiebreaks, present only so the order is +//! 1. **Merged patches** — a patch that folds several advisories into one +//! blob (see [`merged_coverage`]), newest first. A merged patch is the +//! cumulative fix for its package, so the most recent one wins outright. +//! 2. **Everything else** — by severity (critical > high > medium/moderate > +//! low > unknown, the worst severity across everything the patch fixes), +//! then newest first. +//! 3. Paid tier, then UUID — pure tiebreaks, present only so the order is //! total and therefore reproducible run to run. //! -//! # Why severity sits above merge state +//! "Newest" is the date *the patch* was published, never the date the +//! upstream package version was released (see +//! [`crate::api::types::PatchResponse::published_at`]). //! -//! The merged patch is the general preference: it fixes the most in one -//! shot, and the manifest only holds one patch per PURL, so breadth is -//! what an operator actually wants. But it must not shadow a *worse* -//! vulnerability. If a newly published patch addresses a higher-severity -//! advisory than anything the merged patch covers, that one wins — you do -//! not leave a critical unfixed to pick up two extra mediums. -//! -//! Putting severity on the top rung expresses exactly that, because the -//! severity of a patch is the *worst* advisory it fixes: -//! -//! | merged patch | rival patch | winner | why | -//! |---|---|---|---| -//! | high | critical | rival | higher severity available | -//! | critical | high | merged | merged already covers the worst | -//! | high | high | merged | severities tie → breadth decides | -//! -//! Note what is *not* a ranking signal: `tier` is an access filter. A free -//! critical patch outranks a paid low one. +//! `tier` is an access filter, not a ranking signal: callers drop the paid +//! patches a free user cannot download before ranking. use std::cmp::{Ordering, Reverse}; @@ -88,12 +71,8 @@ pub fn max_severity_order<'a>(severities: impl Iterator) -> u8 { /// one advisory routinely carries several CVE aliases, and counting those /// would inflate a single-fix patch into a phantom merged one. /// -/// Empirically, production publishes no merged patches yet — all 28 -/// patches sampled across npm/PyPI/gem/cargo on 2026-08-05 covered exactly -/// one advisory each, so this returns `1` for every patch live today. That -/// is the correct answer, not a degenerate one: the ranking simply falls -/// through to recency, and the moment Socket publishes a consolidated -/// patch it is preferred automatically, with no client or server change. +/// Production published its first merged patch on 2026-09-04 +/// (activestorage 6.0.3, three advisories). pub fn merged_coverage(advisory_count: usize) -> usize { advisory_count } @@ -105,63 +84,68 @@ pub fn merged_coverage(advisory_count: usize) -> usize { /// meaning of each position is documented in one place. #[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] struct RankKey<'a> { - /// 0 = critical … 4 = unknown. Top rung — see the module docs for why - /// this outranks merge state. + /// `false` sorts first: merged patches lead. + not_merged: bool, + /// 0 = critical … 4 = unknown. Always 0 for a merged patch, so merged + /// patches rank by recency alone. severity: u8, - /// Advisory count, most first (hence `Reverse`): the inferred merge - /// state from [`merged_coverage`]. Below severity so a merged patch - /// can never shadow a higher-severity fix; above recency so breadth - /// beats freshness when the severities tie. - coverage: Reverse, - /// Newest **patch** first — the patch's own publication date, not the - /// package's release date. Unparseable or absent timestamps collapse - /// to 0 and therefore sort last: the right treatment for a date we - /// cannot trust, and the reason this is epoch seconds rather than the - /// raw string (see [`crate::api::date`]). + /// Newest patch first. Unparseable or absent timestamps collapse to 0 + /// and sort last (see [`crate::api::date`]). patch_published: Reverse, - /// `false` sorts first, so paid leads. A tiebreak only: it can never - /// override severity or recency. + /// `false` sorts first, so paid leads on an otherwise exact tie. not_paid: bool, - /// Total-order backstop. Without it, two patches identical in every - /// ranked dimension would keep their incoming (server / HashMap) order - /// and the CLI's output would not be reproducible. + /// Total-order backstop, so the output is reproducible. uuid: &'a str, } -fn rank_search_result(p: &PatchSearchResult) -> RankKey<'_> { +fn rank_key<'a>( + severity: u8, + advisories: usize, + published: u64, + tier: &str, + uuid: &'a str, +) -> RankKey<'a> { + let merged = merged_coverage(advisories) >= 2; RankKey { - severity: max_severity_order(p.vulnerabilities.values().map(|v| v.severity.as_str())), - // The map is keyed by advisory id, so its length IS the advisory - // count — no CVE-alias inflation. - coverage: Reverse(merged_coverage(p.vulnerabilities.len())), - patch_published: Reverse(parse_timestamp_secs(&p.published_at).unwrap_or(0)), - not_paid: p.tier != "paid", - uuid: &p.uuid, + not_merged: !merged, + severity: if merged { 0 } else { severity }, + patch_published: Reverse(published), + not_paid: tier != "paid", + uuid, } } +fn rank_search_result(p: &PatchSearchResult) -> RankKey<'_> { + // The map is keyed by advisory id, so its length is the advisory count + // (no CVE-alias inflation). + rank_key( + max_severity_order(p.vulnerabilities.values().map(|v| v.severity.as_str())), + p.vulnerabilities.len(), + parse_timestamp_secs(&p.published_at).unwrap_or(0), + &p.tier, + &p.uuid, + ) +} + fn rank_batch_info(p: &BatchPatchInfo) -> RankKey<'_> { - // `ghsa_ids` is the batch shape's mirror of the `vulnerabilities` map - // keys, so it is the advisory count. Fall back to `cve_ids` only when - // the server named no GHSA at all — otherwise a single advisory with - // two CVE aliases would read as a merged patch. + // `ghsa_ids` mirrors the `vulnerabilities` map keys. Fall back to + // `cve_ids` only when the server named no GHSA at all, otherwise one + // advisory with two CVE aliases would read as a merged patch. let advisories = if p.ghsa_ids.is_empty() { p.cve_ids.len() } else { p.ghsa_ids.len() }; - RankKey { - severity: severity_order(p.severity.as_deref()), - coverage: Reverse(merged_coverage(advisories)), - patch_published: Reverse( - p.published_at - .as_deref() - .and_then(parse_timestamp_secs) - .unwrap_or(0), - ), - not_paid: p.tier != "paid", - uuid: &p.uuid, - } + rank_key( + severity_order(p.severity.as_deref()), + advisories, + p.published_at + .as_deref() + .and_then(parse_timestamp_secs) + .unwrap_or(0), + &p.tier, + &p.uuid, + ) } /// Compare two search results best-first. Pass straight to `sort_by`. @@ -171,9 +155,9 @@ pub fn cmp_search_results(a: &PatchSearchResult, b: &PatchSearchResult) -> Order /// Compare two batch-shaped patches best-first. Pass straight to `sort_by`. /// -/// Ranks on the same key as [`cmp_search_results`], but the batch shape -/// carries a server-computed max `severity` instead of a vulnerability map, -/// and may omit `publishedAt` entirely. +/// Same key as [`cmp_search_results`], but the batch shape carries a +/// server-computed max `severity` instead of a vulnerability map, and may +/// omit `publishedAt` entirely. pub fn cmp_batch_infos(a: &BatchPatchInfo, b: &BatchPatchInfo) -> Ordering { rank_batch_info(a).cmp(&rank_batch_info(b)) } @@ -336,23 +320,41 @@ mod tests { } #[test] - fn a_higher_severity_patch_beats_the_merged_one() { - // The exception. The merged patch consolidates two HIGHs, but a - // rival addresses a CRITICAL it does not cover. Taking breadth here - // would leave the worst vulnerability unfixed, so the CRITICAL - // wins — even though it is older, single-advisory, and its uuid - // sorts last. + fn a_merged_patch_beats_a_higher_severity_single_one() { + // A merged patch is the package's cumulative fix, so it wins even + // against a single-advisory CRITICAL that is its only rival. assert_eq!( best_search(vec![ + search("a_critical", "free", "2026-08-01T00:00:00Z", "critical"), search_multi( - "a_merged", + "z_merged", "free", - "2026-08-01T00:00:00Z", + "2020-01-01T00:00:00Z", &["high", "high"] ), - search("z_critical", "free", "2020-01-01T00:00:00Z", "critical"), ]), - "z_critical" + "z_merged" + ); + } + + #[test] + fn the_newest_merged_patch_wins_whatever_its_severity() { + assert_eq!( + best_search(vec![ + search_multi( + "a_old_critical", + "free", + "2020-01-01T00:00:00Z", + &["critical", "high"] + ), + search_multi( + "z_new_low", + "free", + "2026-08-01T00:00:00Z", + &["low", "low"] + ), + ]), + "z_new_low" ); } @@ -420,20 +422,18 @@ mod tests { } #[test] - fn patch_naming_no_advisory_ranks_below_a_single_advisory_patch() { - // Coverage 0: nothing to prefer it for. It is also newer and - // earlier by uuid, so only the coverage rung demotes it. + fn a_patch_naming_no_advisory_is_not_merged() { + // Coverage 0 and 1 are both unmerged, so with severities tied the + // newer patch wins. let none = PatchSearchResult { vulnerabilities: HashMap::new(), - ..search("a_none", "free", "2026-08-01T00:00:00Z", "high") + ..search("z_none", "free", "2026-08-01T00:00:00Z", "high") }; - // Give both the same (unknown) severity so coverage is the decider: - // an empty vulnerabilities map ranks `severity_order(None)`. let one = PatchSearchResult { vulnerabilities: vulns(&[("GHSA-x", "not-a-severity")]), - ..search("z_one", "free", "2020-01-01T00:00:00Z", "high") + ..search("a_one", "free", "2020-01-01T00:00:00Z", "high") }; - assert_eq!(best_search(vec![none, one]), "z_one"); + assert_eq!(best_search(vec![none, one]), "z_none"); } #[test] @@ -674,7 +674,7 @@ mod tests { ]), "z_merged" ); - // ...and a higher-severity rival still beats the merged patch. + // ...and beats a higher-severity single-advisory rival too. assert_eq!( best_batch(vec![ batch_multi( @@ -691,7 +691,7 @@ mod tests { Some("critical") ), ]), - "z_crit" + "a_merged" ); } From b0a2ce6b30b96cf4b6607b9e2afb1b4d6826d9cd Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Sun, 27 Sep 2026 12:37:06 -0400 Subject: [PATCH 03/32] Make scan default to hosted mode and never prompt A bare scan now runs hosted mode: it rewrites lockfiles so only the patched dependencies resolve to Socket-hosted packages. A path-scoped, --prune or global scan with no mode only reports, since it has no lockfile to rewire. scan asks nothing any more: no confirm prompt in any mode and no patch menu. It takes the top-ranked patch per package. New --package filter (repeatable or comma-separated, env SOCKET_SCAN_PACKAGES) scopes a scan to packages by name or purl. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../socket-patch-cli/src/commands/scan/mod.rs | 206 +++++++----- .../src/commands/scan/render.rs | 95 +----- crates/socket-patch-cli/src/ui/mod.rs | 7 +- crates/socket-patch-cli/src/ui/prompt.rs | 10 +- .../socket-patch-cli/tests/cli_parse_scan.rs | 18 +- .../socket-patch-cli/tests/cli_scan_silent.rs | 4 + .../tests/covgap_commands_scan_mod.rs | 312 ++++-------------- .../tests/in_process_cargo_apply.rs | 3 + .../tests/in_process_gem_apply.rs | 2 + .../tests/in_process_gem_multi_platform.rs | 1 + .../tests/in_process_pypi_apply.rs | 4 + .../tests/in_process_pypi_multi_release.rs | 1 + .../tests/in_process_python_envs.rs | 1 + .../tests/in_process_redirect.rs | 1 + .../tests/in_process_redirect_pdm.rs | 1 + .../tests/in_process_redirect_pipenv.rs | 1 + .../tests/in_process_redirect_pnpm.rs | 1 + .../tests/in_process_redirect_poetry.rs | 1 + .../in_process_remote_ecosystems_apply.rs | 1 + .../tests/in_process_rollback_hosted.rs | 1 + .../socket-patch-cli/tests/in_process_scan.rs | 2 + .../tests/in_process_vendor.rs | 1 + .../socket-patch-cli/tests/scan_invariants.rs | 10 +- .../socket-patch-cli/tests/telemetry_e2e.rs | 2 +- 24 files changed, 250 insertions(+), 436 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 8a3c52ef..be784d8b 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -239,6 +239,14 @@ pub fn resolve_mode_flags(args: &mut ScanArgs) -> Result<(), String> { args.mode = Some(ScanMode::Vendored); } else if args.apply || args.sync { args.mode = Some(ScanMode::Agent); + } else if args.paths.is_empty() + && !args.prune + && !args.common.global + && args.common.global_prefix.is_none() + { + // v5: hosted is the default. A path-scoped, `--prune` or global scan + // with no mode stays report-only (none of them can rewire lockfiles). + args.mode = Some(ScanMode::Hosted); } if !args.paths.is_empty() && matches!(args.mode, Some(ScanMode::Hosted) | Some(ScanMode::Vendored)) @@ -377,6 +385,17 @@ pub struct ScanArgs { )] pub all_releases: bool, + /// Only scan these packages: a name (`lodash`, `@scope/pkg`, + /// `requests`), or a purl with or without its version + /// (`pkg:npm/lodash`, `pkg:pypi/requests@2.31.0`). Repeat the flag or + /// separate with commas + #[arg( + long = "package", + env = "SOCKET_SCAN_PACKAGES", + value_delimiter = ',' + )] + pub packages: Vec, + /// On a successful scan, also generate an OpenVEX 0.2.0 document. /// `--vex ` is the trigger; the `--vex-*` knobs mirror the /// standalone `vex` command. The document is built from the manifest @@ -387,6 +406,44 @@ pub struct ScanArgs { pub vex: VexEmbedArgs, } +/// Whether a `--package` spec names the package at `purl`: a purl spec +/// matches the same purl, or any version of it when it carries none; a +/// bare spec matches the package's full name (`@scope/pkg`, `group/name`) +/// or its last segment. Qualifiers are ignored and names compare +/// case-insensitively (PyPI, NuGet and Composer names are case-insensitive; +/// npm forbids uppercase). +pub(crate) fn package_spec_matches(spec: &str, purl: &str) -> bool { + let decoded = normalize_purl(strip_purl_qualifiers(purl)).to_lowercase(); + let spec = spec.trim().to_lowercase(); + if spec.is_empty() { + return false; + } + let Some(rest) = decoded.strip_prefix("pkg:") else { + return false; + }; + let Some((_eco, name_version)) = rest.split_once('/') else { + return false; + }; + let name = match name_version.rfind('@').filter(|&i| i > 0) { + Some(at) => &name_version[..at], + None => name_version, + }; + if let Some(spec_rest) = spec.strip_prefix("pkg:") { + let spec_purl = normalize_purl(strip_purl_qualifiers(&format!("pkg:{spec_rest}"))).to_lowercase(); + let spec_rest = &spec_purl[4..]; + let has_version = spec_rest + .split_once('/') + .is_some_and(|(_, nv)| nv.rfind('@').is_some_and(|i| i > 0)); + return if has_version { + decoded == spec_purl + } else { + decoded.strip_prefix(&spec_purl).is_some_and(|tail| tail.starts_with('@')) + }; + } + let spec = spec.replace(':', "/"); + name == spec || name.rsplit('/').next() == Some(spec.as_str()) +} + /// Embedded-VEX side-effect for `scan`'s JSON terminal returns. When /// `--vex` was requested and `base_code` is 0, generate the OpenVEX /// document from the post-scan manifest and fold the outcome into @@ -593,17 +650,13 @@ async fn discover_selected( .map_err(|code| (code, "patch selection failed".to_string())) } -/// `common` with `json` off, for `select_patches`: scan has no "re-run -/// with the chosen UUID" path, so it must never get `selection_required`. -/// A `--json` run also counts as `--yes`: it must never stop at the -/// interactive patch menu (on a TTY that menu would block a machine -/// consumer), so it takes the menu's default, the top-ranked patch. -/// (It still keeps the non-interactive note off stderr: the process-wide -/// quiet switch mutes it.) +/// `common` for `select_patches`: scan never prompts, so it always takes +/// the top-ranked patch, and with `json` off it never gets +/// `selection_required` (scan has no "re-run with the chosen UUID" path). fn selection_args(common: &GlobalArgs) -> GlobalArgs { GlobalArgs { json: false, - yes: common.yes || common.json, + yes: true, ..common.clone() } } @@ -1834,6 +1887,19 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { all_crawled }; + let filtered_crawled: Vec<_> = if args.packages.is_empty() { + filtered_crawled + } else { + filtered_crawled + .into_iter() + .filter(|pkg| { + args.packages + .iter() + .any(|spec| package_spec_matches(spec, &pkg.purl)) + }) + .collect() + }; + // PATH scoping — applied strictly AFTER the `scanned_purls` capture // above (the prune universe stays full-crawl: `scan PATHS --prune` // must never treat out-of-scope packages as uninstalled) and after the @@ -2840,33 +2906,6 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { return code; } }; - // The engine honors `--dry-run` itself (a preview mutates nothing), - // so only a wet run with work confirms. `--mode hosted` is explicit - // intent, so a non-TTY run auto-proceeds like every other mode — - // only the mode-less scan below is report-only. - let prompts = !selected.is_empty() && !args.common.dry_run; - // Whether that prompt waits on a person: the tree may change while it - // does, so the embedded VEX then walks node_modules afresh instead of - // reusing the pre-prompt crawl (as the vendor path below does). - let prompt_waits = prompts && ui::confirm_waits(&args.common); - if prompts { - let prompt = render::hosted_confirm_prompt(selected.len()); - // The prompt (or the non-TTY note) opens its own paragraph - // under the table's Summary, on the prompt's stream. - if !silent && !args.common.yes { - eprintln!(); - } - if !ui::confirm(&prompt, true, &args.common) { - if !silent { - println!(); - for line in render::hosted_decline_hint() { - println!("{line}"); - } - } - warn_unreported_corrupt_ledger(&args.common, hosted_corrupt_ledger.as_deref()); - return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; - } - } let pairs: Vec<(String, String)> = selected .iter() .map(|s| (s.purl.clone(), s.uuid.clone())) @@ -2878,7 +2917,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { &api_client, &pairs, None, - npm_crawl.as_ref().filter(|_| !prompt_waits), + npm_crawl.as_ref(), ) .await; } @@ -2894,14 +2933,10 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { return 1; } - // Prompt to download. A MODE-LESS human scan (no `--mode`/`--apply`/ - // `--sync`/`--vendor`/`--redirect` and no `--prune`) with a non-TTY - // stdin and no `--yes` is report-only: it stops before the prompt with - // exit 0 and a hint, never downloads, never creates `.socket/`. This is - // a scan-side pre-check — `confirm()` itself keeps its non-TTY - // auto-accept, so every explicit-intent flag (and every other command's - // prompt) still proceeds unattended, and a TTY always prompts. - let report_only = args.mode.is_none() && !args.prune && !args.common.yes && !ui::stdin_is_tty(); + // Scan never prompts. A scan left without a mode (path-scoped, + // `--prune` or global; see `resolve_mode_flags`) only reports, plus the + // `--prune` GC. + let report_only = args.mode.is_none(); // Smart selection. A report-only run picks without the non-interactive // note: it never downloads, so there is no pick to announce. @@ -3071,19 +3106,28 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { return finish_human(0).await; } - // Report-only (see `report_only` above): stop before the prompt. if report_only { // The "Patches to apply:" listing already ends with a blank line. if !silent { - for line in render::decline_hint(false) { + for line in render::report_only_hint() { println!("{line}"); } } + if prune { + gc::run_human_gc( + &args.common, + &manifest_path, + &socket_dir, + &scanned_purls, + &vendored_purls, + ) + .await; + } return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; } - // Vendor mode: pre-verify baselines so a content mismatch surfaces - // BEFORE the confirm prompt (vendoring still proceeds for these — the + // Vendor mode: pre-verify baselines so a content mismatch is reported + // before vendoring starts (vendoring still proceeds for these — the // stage force-applies the verified patched content). Runs after the // dry-run return above so a preview fetches no views; the views it // does fetch seed the download phase, which never fetches them again. @@ -3105,7 +3149,6 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { ); any_mismatch = true; } - // Keep the prompt its own paragraph, as in the other flows. if any_mismatch { println!(); } @@ -3114,23 +3157,6 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { HashMap::new() }; - // Whether the prompt below waits on a person: the tree may change while - // it does, so the vendor step then crawls afresh instead of reusing the - // pre-prompt crawl (`--yes` / `--json` / non-terminal answer at once). - let prompt_waits = ui::confirm_waits(&args.common); - if !ui::confirm(&render::confirm_prompt(plan), true, &args.common) { - if !silent { - println!(); - for line in render::decline_hint(vendor) { - println!("{line}"); - } - if prune { - eprintln!("{}", render::PRUNE_SKIPPED_DECLINED); - } - } - return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; - } - // Download, then apply in place — or vendor (vendored mode, where the // download only saves and the vendor step below does the rest). let params = download_params( @@ -3154,7 +3180,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { prune, telemetry_token.as_deref(), telemetry_org.as_deref(), - npm_crawl.as_ref().filter(|_| !prompt_waits), + npm_crawl.as_ref(), ) .await } else { @@ -3204,6 +3230,28 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { mod tests { use super::*; + #[test] + fn package_specs_match_names_and_purls() { + let lodash = "pkg:npm/lodash@4.17.20"; + let scoped = "pkg:npm/%40babel/core@7.0.0"; + let maven = "pkg:maven/org.apache/commons-text@1.9"; + assert!(package_spec_matches("lodash", lodash)); + assert!(package_spec_matches("LoDash", lodash)); + assert!(package_spec_matches("pkg:npm/lodash", lodash)); + assert!(package_spec_matches("pkg:npm/lodash@4.17.20", lodash)); + assert!(!package_spec_matches("pkg:npm/lodash@4.17.21", lodash)); + assert!(!package_spec_matches("pkg:pypi/lodash", lodash)); + assert!(!package_spec_matches("lodash-es", lodash)); + assert!(!package_spec_matches("pkg:npm/lodash-es", lodash)); + assert!(package_spec_matches("@babel/core", scoped)); + assert!(package_spec_matches("core", scoped)); + assert!(package_spec_matches("pkg:npm/@babel/core", scoped)); + assert!(package_spec_matches("pkg:npm/%40babel/core", scoped)); + assert!(package_spec_matches("org.apache:commons-text", maven)); + assert!(package_spec_matches("commons-text", maven)); + assert!(!package_spec_matches("", lodash)); + } + /// The load-then-derive form of [`overlap_from_states`]: the unit /// tests' entry point (production classifies over ledgers it already /// holds via `classify_overlap_takeover_with`). A malformed redirect @@ -3482,19 +3530,17 @@ mod tests { } #[test] - fn selection_args_never_leaves_json_at_the_patch_menu() { - let json = selection_args(&GlobalArgs { - json: true, - ..GlobalArgs::default() - }); - assert!(!json.json && json.yes, "--json selects like --yes"); - let human = selection_args(&GlobalArgs::default()); - assert!(!human.json && !human.yes, "a human run keeps its menu"); - let yes = selection_args(&GlobalArgs { - yes: true, - ..GlobalArgs::default() - }); - assert!(yes.yes); + fn selection_args_never_prompts() { + for common in [ + GlobalArgs::default(), + GlobalArgs { + json: true, + ..GlobalArgs::default() + }, + ] { + let picked = selection_args(&common); + assert!(!picked.json && picked.yes, "scan always takes the top patch"); + } } #[test] diff --git a/crates/socket-patch-cli/src/commands/scan/render.rs b/crates/socket-patch-cli/src/commands/scan/render.rs index 65af51bc..f9c28fc8 100644 --- a/crates/socket-patch-cli/src/commands/scan/render.rs +++ b/crates/socket-patch-cli/src/commands/scan/render.rs @@ -223,11 +223,7 @@ pub(super) const PRUNE_SKIPPED_EMPTY: &str = "Warning: --prune skipped: no insta were found, and pruning every manifest entry is too destructive to do implicitly; run \ `socket-patch repair` to clean up .socket/ explicitly."; -/// Note printed when the user declines the download prompt of a -/// `--prune` run: nothing is changed, the GC included. -pub(super) const PRUNE_SKIPPED_DECLINED: &str = "Note: --prune skipped (download declined)."; - -/// What the confirm prompt / dry-run line is about to do. +/// What the dry-run line says the run would do. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) enum Plan { /// Agent mode: download `n` patches and apply them in place. @@ -236,22 +232,6 @@ pub(super) enum Plan { Vendor(usize), } -/// The confirm prompt for `plan` (the `[Y/n]` hint is added by the prompt). -pub(super) fn confirm_prompt(plan: Plan) -> String { - match plan { - Plan::Apply(n) => format!("Download and apply {}?", plural(n, "patch", "patches")), - Plan::Vendor(n) => format!("Download and vendor {}?", plural(n, "patch", "patches")), - } -} - -/// The hosted-mode confirm prompt (the `[Y/n]` hint is added by the prompt). -pub(super) fn hosted_confirm_prompt(n: usize) -> String { - format!( - "Redirect {} to the hosted patch server?", - plural(n, "package", "packages") - ) -} - /// The `--dry-run` headline. `refused` counts the patches the vendored /// preflight would refuse (listed right after as `[would-refuse]` lines), /// so the headline never promises to vendor what the wet run refuses. @@ -268,36 +248,17 @@ pub(super) fn dry_run_line(plan: Plan, refused: usize) -> String { format!("[dry-run] Would {what}. No changes made.") } -/// Lines printed after the user declines the prompt: how to pick patches -/// one at a time in the same mode. -pub(super) fn decline_hint(vendor: bool) -> [String; 3] { - if vendor { - [ - "To vendor a single patch, run:".to_string(), - " socket-patch get --mode vendored".to_string(), - " socket-patch get --mode vendored".to_string(), - ] - } else { - [ - "To apply a single patch, run:".to_string(), - " socket-patch get ".to_string(), - " socket-patch get ".to_string(), - ] - } -} - -/// Lines printed after the user declines the hosted-mode prompt: how to -/// redirect packages one at a time (`get` defaults to agent mode, so the -/// mode is named). -pub(super) fn hosted_decline_hint() -> [String; 3] { +/// Lines printed after a report-only scan (path-scoped, `--prune` or +/// global, so no lockfile to rewire): how to apply what it found. +pub(super) fn report_only_hint() -> [String; 3] { [ - "To redirect a package, run:".to_string(), - " socket-patch get --mode hosted".to_string(), - " socket-patch get --mode hosted".to_string(), + "To apply these patches in place, run:".to_string(), + " socket-patch scan --mode agent [PATHS]".to_string(), + " socket-patch get ".to_string(), ] } -/// Printed (vendored mode, before the prompt) for a selected package whose +/// Printed (vendored mode, before vendoring) for a selected package whose /// installed bytes differ from the patch baseline: vendoring still /// proceeds, with the verified patched content. pub(super) fn baseline_mismatch_line(purl: &str) -> String { @@ -741,31 +702,7 @@ mod tests { assert_eq!(generic, no_packages_message(false, None, &[])); } - // ---- prompt / dry-run / hints ------------------------------------------- - - #[test] - fn confirm_prompt_counts_patches() { - assert_eq!( - confirm_prompt(Plan::Apply(1)), - "Download and apply 1 patch?" - ); - assert_eq!( - confirm_prompt(Plan::Apply(3)), - "Download and apply 3 patches?" - ); - assert_eq!( - confirm_prompt(Plan::Vendor(2)), - "Download and vendor 2 patches?" - ); - assert_eq!( - hosted_confirm_prompt(1), - "Redirect 1 package to the hosted patch server?" - ); - assert_eq!( - hosted_confirm_prompt(2), - "Redirect 2 packages to the hosted patch server?" - ); - } + // ---- dry-run / hints ------------------------------------------- #[test] fn dry_run_line_counts_refusals() { @@ -788,17 +725,9 @@ mod tests { } #[test] - fn decline_hint_matches_mode() { - assert_eq!(decline_hint(false)[0], "To apply a single patch, run:"); - assert!(decline_hint(false).iter().all(|l| !l.contains("--mode"))); - assert_eq!(decline_hint(true)[0], "To vendor a single patch, run:"); - assert!(decline_hint(true)[1..] - .iter() - .all(|l| l.ends_with(" --mode vendored"))); - assert_eq!(hosted_decline_hint()[0], "To redirect a package, run:"); - assert!(hosted_decline_hint()[1..] - .iter() - .all(|l| l.ends_with(" --mode hosted"))); + fn report_only_hint_names_agent_mode() { + assert_eq!(report_only_hint()[0], "To apply these patches in place, run:"); + assert!(report_only_hint()[1].contains("--mode agent")); } #[test] diff --git a/crates/socket-patch-cli/src/ui/mod.rs b/crates/socket-patch-cli/src/ui/mod.rs index ae9f7225..3f0f651e 100644 --- a/crates/socket-patch-cli/src/ui/mod.rs +++ b/crates/socket-patch-cli/src/ui/mod.rs @@ -21,7 +21,7 @@ use std::io::IsTerminal; use crate::args::GlobalArgs; -pub(crate) use prompt::{confirm, confirm_or_proceed, confirm_waits}; +pub(crate) use prompt::{confirm, confirm_or_proceed}; pub use prompt::{select_one, SelectError}; pub(crate) use status::StatusLine; pub(crate) use text::{plural, truncate}; @@ -48,11 +48,6 @@ pub(crate) fn print_json(v: &serde_json::Value) { ); } -/// Whether stdin is a terminal a person can answer prompts on. -pub(crate) fn stdin_is_tty() -> bool { - std::io::stdin().is_terminal() -} - /// Whether `--silent`/`--json` is in effect for this process (see [`init`]). pub(crate) fn quiet() -> bool { socket_patch_core::utils::notice::is_quiet() diff --git a/crates/socket-patch-cli/src/ui/prompt.rs b/crates/socket-patch-cli/src/ui/prompt.rs index a48df963..e9eabc8f 100644 --- a/crates/socket-patch-cli/src/ui/prompt.rs +++ b/crates/socket-patch-cli/src/ui/prompt.rs @@ -42,11 +42,7 @@ pub(crate) fn confirm(prompt: &str, default_yes: bool, common: &GlobalArgs) -> b ) } -/// Whether [`confirm`] would stop and wait for a person to answer — a -/// caller's clue that the world may change while it does (`scan` reuses a -/// crawl across the prompt only when it does not wait). Derived from -/// `confirm` itself rather than hand-copied at the call site: the drift -/// that matters is the unsafe direction, a wait nobody accounted for. +/// Whether [`confirm`] would stop and wait for a person to answer. pub(crate) fn confirm_waits(common: &GlobalArgs) -> bool { !(common.yes || common.json) && io::stdin().is_terminal() } @@ -543,8 +539,8 @@ mod tests { )); } - /// `confirm_waits` is what `scan` plans its crawl reuse around, so it - /// must never say "no wait" for a case `confirm` would stop on. Over + /// `confirm_waits` must never say "no wait" for a case `confirm` would + /// stop on. Over /// the whole `{yes, json}` cube with this process's stdin (a pipe /// under the test harness, so never a terminal), it says no wait — /// and `confirm` indeed answers from its default without reading a diff --git a/crates/socket-patch-cli/tests/cli_parse_scan.rs b/crates/socket-patch-cli/tests/cli_parse_scan.rs index 3cb0cd32..ff27865a 100644 --- a/crates/socket-patch-cli/tests/cli_parse_scan.rs +++ b/crates/socket-patch-cli/tests/cli_parse_scan.rs @@ -518,6 +518,15 @@ fn scan_json_empty_cwd_emits_updates_key() { // v4 duality rework: the positional PATH globs are echoed on every // scan envelope, always present (empty when no scoping was given). "paths": [], + // v5: a bare scan runs hosted mode, so its result nests here. + "redirect": { + "mode": "hosted", + "redirected": 0, + "rewrittenFiles": [], + "skipped": [], + "warnings": [], + "dryRun": false + }, }); assert_eq!( v, @@ -611,9 +620,14 @@ fn mode_agent_is_the_source_of_truth() { "--sync == --mode agent --prune" ); assert!(folded.sync, "the prune half of --sync stays readable"); - // No mode selected at all: scan stays read-only. + // v5: no mode selected means hosted... let folded = parse_and_resolve(&[]).expect("fold ok"); - assert_eq!(folded.mode, None, "modeless scan is read-only"); + assert_eq!(folded.mode, Some(ScanMode::Hosted), "a bare scan is hosted"); + // ...except where no lockfile can be rewired: those stay report-only. + for argv in [&["--prune"][..], &["packages/foo"], &["--global"]] { + let folded = parse_and_resolve(argv).expect("fold ok"); + assert_eq!(folded.mode, None, "{argv:?} stays report-only"); + } } #[test] diff --git a/crates/socket-patch-cli/tests/cli_scan_silent.rs b/crates/socket-patch-cli/tests/cli_scan_silent.rs index acdb82ca..31cb2710 100644 --- a/crates/socket-patch-cli/tests/cli_scan_silent.rs +++ b/crates/socket-patch-cli/tests/cli_scan_silent.rs @@ -225,6 +225,8 @@ async fn scan_silent_apply_flow_produces_no_output_but_still_applies() { let (code, stdout, stderr) = run_scan( tmp.path(), &[ + "--mode", + "agent", "--silent", "--yes", "--api-url", @@ -274,6 +276,8 @@ async fn scan_silent_apply_flow_produces_no_output_but_still_applies() { let (loud_code, loud_stdout, loud_stderr) = run_scan( tmp2.path(), &[ + "--mode", + "agent", "--yes", "--api-url", &mock.uri(), diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs index 532f076f..a82258b2 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs @@ -115,6 +115,14 @@ fn run_scan_human(cwd: &Path, api_url: &str, extra: &[&str]) -> (i32, String, St run_scan(cwd, &args) } +/// [`run_scan_human`] in agent mode: v5's bare scan is hosted, and these +/// fixtures exercise the in-place apply flow. +fn run_scan_agent(cwd: &Path, api_url: &str, extra: &[&str]) -> (i32, String, String) { + let mut args = vec!["--mode", "agent"]; + args.extend_from_slice(extra); + run_scan_human(cwd, api_url, &args) +} + async fn recorded(mock: &MockServer) -> Vec { mock.received_requests() .await @@ -579,7 +587,7 @@ async fn scan_paid_patch_with_access_counts_all_and_reports_detail_failure() { write_root_package_json(tmp.path()); write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); - let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &[]); + let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &[]); assert_eq!( code, 1, "a failed detail fetch fails the scan; stdout={stdout}" @@ -651,7 +659,7 @@ async fn scan_human_table_renders_update_marker_and_vuln_overflow() { // --dry-run keeps the run read-only past the table (the confirm and // apply never run), so no view/blob mocks are needed. - let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &["--dry-run", "--yes"]); + let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &["--dry-run", "--yes"]); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); assert!( stdout.contains("[UPDATE]"), @@ -693,7 +701,7 @@ async fn scan_human_detail_fetch_failure_errors_once() { write_root_package_json(tmp.path()); write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); - let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &[]); + let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &[]); assert_eq!(code, 1, "stdout={stdout}; stderr={stderr}"); assert!( stderr.contains(&format!( @@ -748,7 +756,7 @@ async fn scan_human_partial_detail_fetch_failure_warns_per_package() { write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); write_npm_package(tmp.path(), "lodash", "4.17.20", b"x\n"); - let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &["--dry-run"]); + let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &["--dry-run"]); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); assert_eq!( stderr @@ -804,7 +812,7 @@ async fn scan_human_skips_vendored_purls_without_downloading() { ) .unwrap(); - let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &["--yes"]); + let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &["--yes"]); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); assert!( stdout.contains(&format!( @@ -864,7 +872,7 @@ async fn scan_human_preview_renders_vulnerability_details() { write_root_package_json(tmp.path()); write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); - let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &["--dry-run", "--yes"]); + let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &["--dry-run", "--yes"]); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); assert!( stdout.contains("Fixes: "), @@ -905,7 +913,7 @@ async fn scan_human_dry_run_vex_prints_the_shared_skip_line() { write_root_package_json(tmp.path()); write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); - let (code, stdout, stderr) = run_scan_human( + let (code, stdout, stderr) = run_scan_agent( tmp.path(), &mock.uri(), &["--dry-run", "--yes", "--vex", "out.vex.json"], @@ -1076,7 +1084,7 @@ async fn scan_human_apply_over_live_hosted_wiring_warns_retained() { ) .unwrap(); - let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &["--yes"]); + let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &["--yes"]); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); assert!( stderr.contains("Warning (hosted_wiring_retained):"), @@ -1266,18 +1274,15 @@ async fn scan_human_empty_batch_reports_no_patches_once() { // Non-TTY human scans: the mode-less scan is report-only, explicit intent // auto-proceeds // --------------------------------------------------------------------------- -// Every `Command::output()` child here has a non-TTY stdin. A bare `scan` -// (no `--mode`/`--apply`/`--sync`/`--vendor`/`--redirect`, no `--prune`, -// no `--yes`) must stop BEFORE the download with exit 0 and the get-hint, -// creating nothing under `.socket/`; any intent flag keeps `confirm()`'s -// non-TTY auto-accept and applies. - -/// Bare `scan` piped: the discovery, table and per-patch preview print -/// (the report IS the value), then the run stops — no view fetch, no -/// `.socket/`, the installed file untouched — and `confirm()` was never -/// consulted (no "Non-interactive mode" line). +// v5: scan never prompts. A bare scan runs hosted mode; a path-scoped, +// `--prune` or global scan with no mode only reports (it has no lockfile +// to rewire), and `--mode agent` applies in place without asking. + +/// A path-scoped scan with no mode: the discovery, table and per-patch +/// preview print (the report IS the value), then the run stops — no view +/// fetch, no `.socket/`, the installed file untouched. #[tokio::test] -async fn scan_bare_human_non_tty_is_report_only() { +async fn scan_path_scoped_human_without_a_mode_is_report_only() { let mock = MockServer::start().await; let purl = "pkg:npm/minimist@1.2.2"; mount_one_patch_api(&mock, purl, b"x\n").await; @@ -1286,7 +1291,7 @@ async fn scan_bare_human_non_tty_is_report_only() { write_root_package_json(tmp.path()); write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); - let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &[]); + let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &["node_modules"]); assert_eq!( code, 0, "report-only is a success; stdout={stdout}; stderr={stderr}" @@ -1296,13 +1301,13 @@ async fn scan_bare_human_non_tty_is_report_only() { "the per-patch preview still prints; got {stdout:?}" ); assert!( - stdout.contains("To apply a single patch, run:") - && stdout.contains("socket-patch get "), - "the get-hint must print; got {stdout:?}" + stdout.contains("To apply these patches in place, run:") + && stdout.contains("socket-patch scan --mode agent"), + "the agent-mode hint must print; got {stdout:?}" ); assert!( !stderr.contains("Non-interactive mode detected"), - "confirm() must not be consulted on the report-only path; got {stderr:?}" + "scan never prompts; got {stderr:?}" ); assert!( !tmp.path().join(".socket").exists(), @@ -1321,11 +1326,9 @@ async fn scan_bare_human_non_tty_is_report_only() { ); } -/// The same piped run with an explicit intent flag (each spelling that -/// folds to `--mode agent`) auto-proceeds through `confirm()`'s non-TTY -/// default and applies. +/// Each spelling that folds to `--mode agent` applies without prompting. #[tokio::test] -async fn scan_human_non_tty_explicit_intent_auto_proceeds() { +async fn scan_human_agent_mode_applies_without_prompting() { for flags in [&["--mode", "agent"][..], &["--apply"][..]] { let mock = MockServer::start().await; let purl = "pkg:npm/silent-target@1.0.0"; @@ -1339,8 +1342,8 @@ async fn scan_human_non_tty_explicit_intent_auto_proceeds() { let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), flags); assert_eq!(code, 0, "flags={flags:?}: stdout={stdout}; stderr={stderr}"); assert!( - stderr.contains("Non-interactive mode detected, proceeding automatically."), - "flags={flags:?}: explicit intent keeps confirm()'s non-TTY auto-accept; got {stderr:?}" + !stderr.contains("Non-interactive mode detected"), + "flags={flags:?}: scan never prompts; got {stderr:?}" ); assert_eq!( std::fs::read(tmp.path().join("node_modules/silent-target/index.js")).unwrap(), @@ -1354,22 +1357,17 @@ async fn scan_human_non_tty_explicit_intent_auto_proceeds() { } } -/// `--prune` alone is explicit intent too (it asks for a `.socket/` -/// mutation): the piped run applies AND garbage-collects. +/// `--mode agent --prune` applies AND garbage-collects, unprompted. #[tokio::test] -async fn scan_human_non_tty_prune_counts_as_intent() { +async fn scan_human_agent_prune_applies_and_collects() { let mock = MockServer::start().await; let (code, stdout, stderr, tmp) = run_apply_with_orphans( &mock, &[("pkg:npm/gone@1.0.0", OLD_UUID, 'c')], - &["--prune"], + &["--mode", "agent", "--prune"], ) .await; assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); - assert!( - stderr.contains("Non-interactive mode detected, proceeding automatically."), - "--prune auto-proceeds through confirm(); got {stderr:?}" - ); assert!( stdout.contains("GC: pruned 1 manifest entry and removed 1 orphan file ("), "the GC still runs; got {stdout:?}" @@ -1482,7 +1480,7 @@ fn seed_redirect_ledger(root: &Path, purl: &str, uuid: &str) { } #[tokio::test] -async fn scan_hosted_human_prints_table_updates_and_confirms() { +async fn scan_hosted_human_prints_table_updates_and_redirects() { let mock = MockServer::start().await; let purl = "pkg:npm/minimist@1.2.2"; mount_batch_one(&mock, purl, UUID, "free", &["CVE-2024-0001"], false).await; @@ -1496,7 +1494,8 @@ async fn scan_hosted_human_prints_table_updates_and_confirms() { // flag the newer offer in hosted mode too. seed_redirect_ledger(tmp.path(), purl, OLD_UUID); - let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &["--mode", "hosted"]); + // v5: a bare scan is hosted. + let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &[]); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); assert!( stdout.contains("VULNERABILITIES") && stdout.contains("CVE-2024-0001"), @@ -1510,15 +1509,13 @@ async fn scan_hosted_human_prints_table_updates_and_confirms() { stdout.contains("[UPDATE]") && stdout.contains("1 package has a newer patch available."), "update detection must run in hosted mode; got {stdout:?}" ); - // `--mode hosted` is explicit intent: the new prompt auto-accepts on a - // non-TTY stdin and the engine runs. assert!( - stderr.contains("Non-interactive mode detected, proceeding automatically."), - "the hosted confirm must run (and auto-accept) on a non-TTY; got {stderr:?}" + !stderr.contains("Non-interactive mode detected"), + "scan never prompts; got {stderr:?}" ); assert!( stdout.contains("Redirected 0 packages"), - "the engine must run after the prompt; got {stdout:?}" + "the engine must run; got {stdout:?}" ); let reqs = recorded(&mock).await; assert_eq!( @@ -1538,11 +1535,8 @@ async fn scan_hosted_human_prints_table_updates_and_confirms() { } // --------------------------------------------------------------------------- -// Declined download confirm via PTY (unix only) +// Scan on a PTY (unix only) // --------------------------------------------------------------------------- -// A non-TTY mode-less scan never reaches `confirm()` (report-only above), -// and every explicit-intent run auto-accepts there, so only a PTY reaches -// the decline arm: exit 0, the get-hint, and no mutation. #[cfg(unix)] mod pty { @@ -1643,80 +1637,18 @@ mod pty { ) } + /// v5 scan never prompts, even on a TTY: agent mode applies with no + /// input at all. #[tokio::test(flavor = "multi_thread")] - async fn scan_decline_at_download_prompt_exits_zero_without_mutation() { + async fn scan_on_a_tty_applies_without_prompting() { let mock = MockServer::start().await; - let purl = "pkg:npm/minimist@1.2.2"; - mount_batch_one(&mock, purl, UUID, "free", &[], false).await; - mount_by_package(&mock, purl, UUID, serde_json::json!({})).await; - - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); - - let uri = mock.uri(); - let cwd = tmp.path().to_path_buf(); - let (code, output) = tokio::task::spawn_blocking(move || { - run_in_pty( - &[ - "scan", - "--api-url", - &uri, - "--api-token", - "fake-token-for-test", - "--org", - ORG_SLUG, - ], - &cwd, - "n\n", - Duration::from_secs(60), - ) - }) - .await - .expect("spawn_blocking join"); - - assert_eq!(code, 0, "declining is not an error; output:\n{output}"); - // The prompt genuinely ran (a regression auto-proceeding in a TTY - // would skip it — and would mutate, failing below too). - assert!( - output.contains("Download and apply 1 patch?"), - "the confirm prompt must have shown; got:\n{output}" - ); - assert!( - output.contains("To apply a single patch, run:"), - "the decline hint must print; got:\n{output}" - ); - assert!( - output.contains("socket-patch get "), - "the decline hint names the get command; got:\n{output}" - ); - - // Decline mutates nothing: no manifest, untouched file, no download. - assert!( - !tmp.path().join(".socket/manifest.json").exists(), - "declining must not create the manifest" - ); - assert_eq!( - std::fs::read(tmp.path().join("node_modules/minimist/index.js")).unwrap(), - b"x\n", - "declining must not patch the installed file" - ); - let reqs = recorded(&mock).await; - assert_eq!(view_gets(&reqs), 0, "declining must not download the patch"); - } - - /// Declining the vendored-mode prompt points at the vendored `get`, - /// not the in-place one (which would apply instead of vendoring). - #[tokio::test(flavor = "multi_thread")] - async fn scan_vendored_decline_hint_names_vendored_get() { - let mock = MockServer::start().await; - let purl = "pkg:npm/minimist@1.2.2"; - mount_batch_one(&mock, purl, UUID, "free", &[], false).await; - mount_by_package(&mock, purl, UUID, serde_json::json!({})).await; + let purl = "pkg:npm/silent-target@1.0.0"; + let before = b"before\n"; + mount_one_patch_api(&mock, purl, before).await; let tmp = tempfile::tempdir().unwrap(); write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); + write_npm_package(tmp.path(), "silent-target", "1.0.0", before); let uri = mock.uri(); let cwd = tmp.path().to_path_buf(); @@ -1725,7 +1657,7 @@ mod pty { &[ "scan", "--mode", - "vendored", + "agent", "--api-url", &uri, "--api-token", @@ -1734,26 +1666,22 @@ mod pty { ORG_SLUG, ], &cwd, - "n\n", + "", Duration::from_secs(60), ) }) .await .expect("spawn_blocking join"); - assert_eq!(code, 0, "declining is not an error; output:\n{output}"); - assert!( - output.contains("Download and vendor 1 patch?"), - "the vendored prompt must have shown; got:\n{output}" - ); + assert_eq!(code, 0, "output:\n{output}"); assert!( - output.contains("To vendor a single patch, run:") - && output.contains("socket-patch get --mode vendored"), - "the decline hint must name the vendored get; got:\n{output}" + !output.contains("[Y/n]") && !output.contains("Download and apply"), + "scan must not prompt; got:\n{output}" ); - assert!( - !output.contains("To apply a single patch"), - "no agent-mode hint in vendored mode; got:\n{output}" + assert_eq!( + std::fs::read(tmp.path().join("node_modules/silent-target/index.js")).unwrap(), + b"after\n", + "the apply must proceed unattended" ); } @@ -1921,63 +1849,6 @@ mod pty { } } - /// Keystrokes typed while the scan is still querying the API must not - /// answer the default-yes download prompt: `confirm` discards - /// typeahead before showing it. Without the flush, the early "n\n" - /// would decline; with it, the Enter sent at the prompt takes the - /// default (yes) and the patch is applied. - #[tokio::test(flavor = "multi_thread")] - async fn scan_typeahead_before_the_prompt_is_discarded() { - let mock = MockServer::start().await; - let purl = "pkg:npm/typeahead-target@1.0.0"; - let before = b"before\n"; - // The batch answers late, so the early "n\n" is certainly sitting - // in the terminal's input queue before the prompt appears. - mount_one_patch_api_delayed(&mock, purl, before, Duration::from_millis(1500)).await; - - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "typeahead-target", "1.0.0", before); - - let uri = mock.uri(); - let cwd = tmp.path().to_path_buf(); - let (code, output) = tokio::task::spawn_blocking(move || { - run_in_pty_with( - &[ - "scan", - "--api-url", - &uri, - "--api-token", - "fake-token-for-test", - "--org", - ORG_SLUG, - ], - &cwd, - &[], - "n\n", - "\n", - Duration::from_secs(60), - ) - }) - .await - .expect("spawn_blocking join"); - - assert_eq!(code, 0, "output:\n{output}"); - assert!( - output.contains("Download and apply 1 patch? [Y/n] "), - "the default-yes prompt must have shown; got:\n{output}" - ); - assert!( - !output.contains("To apply a single patch, run:"), - "the early \"n\" must not have declined the prompt; got:\n{output}" - ); - assert_eq!( - std::fs::read(tmp.path().join("node_modules/typeahead-target/index.js")).unwrap(), - b"after\n", - "the Enter at the prompt takes the default and applies; got:\n{output}" - ); - } - /// Under `SOCKET_DEBUG` core prints `[socket-patch debug] ...` lines /// straight to stderr. The live status line is off then, so those /// lines never land glued onto the end of a progress message. @@ -2043,69 +1914,6 @@ mod pty { ); } - /// The hosted twin: declining "Redirect N packages …?" exits 0 with - /// the hosted get-hint and never enters the engine (no reference - /// resolve, no `.socket/`). - #[tokio::test(flavor = "multi_thread")] - async fn scan_hosted_decline_at_redirect_prompt_exits_zero_without_mutation() { - let mock = MockServer::start().await; - let purl = "pkg:npm/minimist@1.2.2"; - mount_batch_one(&mock, purl, UUID, "free", &[], false).await; - mount_by_package(&mock, purl, UUID, serde_json::json!({})).await; - mount_forbidden_reference(&mock, purl).await; - - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); - - let uri = mock.uri(); - let cwd = tmp.path().to_path_buf(); - let (code, output) = tokio::task::spawn_blocking(move || { - run_in_pty( - &[ - "scan", - "--mode", - "hosted", - "--api-url", - &uri, - "--api-token", - "fake-token-for-test", - "--org", - ORG_SLUG, - ], - &cwd, - "n\n", - Duration::from_secs(60), - ) - }) - .await - .expect("spawn_blocking join"); - - assert_eq!(code, 0, "declining is not an error; output:\n{output}"); - assert!( - output.contains("Redirect 1 package to the hosted patch server?"), - "the hosted confirm prompt must have shown; got:\n{output}" - ); - assert!( - output.contains("To redirect a package, run:") - && output.contains("socket-patch get --mode hosted"), - "the hosted decline hint must print; got:\n{output}" - ); - assert!( - !output.contains("Redirected"), - "declining must not enter the redirect engine; got:\n{output}" - ); - assert!( - !tmp.path().join(".socket").exists(), - "declining must create nothing under .socket/" - ); - let reqs = recorded(&mock).await; - assert_eq!( - reference_posts(&reqs), - 0, - "declining must not resolve the hosted reference" - ); - } } // --------------------------------------------------------------------------- @@ -2482,7 +2290,7 @@ async fn scan_human_does_not_offer_an_already_recorded_patch() { write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); seed_manifest(tmp.path(), &[(purl, UUID)]); - let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &["--yes"]); + let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &["--yes"]); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); assert!( stdout.contains(&format!("[skip] {purl} (already recorded: 11111111)")), diff --git a/crates/socket-patch-cli/tests/in_process_cargo_apply.rs b/crates/socket-patch-cli/tests/in_process_cargo_apply.rs index b85ef413..fce505b3 100644 --- a/crates/socket-patch-cli/tests/in_process_cargo_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_cargo_apply.rs @@ -221,6 +221,7 @@ async fn cargo_fetch_scan_sync_patches_real_file() { let args = ScanArgs { paths: Vec::new(), + packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { cwd: tmp.path().join("proj"), org: Some(ORG.to_string()), @@ -337,6 +338,7 @@ async fn cargo_apply_refuses_on_before_hash_mismatch() { let args = ScanArgs { paths: Vec::new(), + packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { cwd: tmp.path().join("proj"), org: Some(ORG.to_string()), @@ -439,6 +441,7 @@ async fn cargo_crawler_finds_real_fetched_crate() { std::env::set_var("CARGO_HOME", &cargo_home); let args = ScanArgs { paths: Vec::new(), + packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { cwd: tmp.path().join("proj"), org: Some(ORG.to_string()), diff --git a/crates/socket-patch-cli/tests/in_process_gem_apply.rs b/crates/socket-patch-cli/tests/in_process_gem_apply.rs index 2f5fd7d2..33d64d1e 100644 --- a/crates/socket-patch-cli/tests/in_process_gem_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_gem_apply.rs @@ -200,6 +200,7 @@ async fn gem_install_scan_sync_patches_real_file() { let args = ScanArgs { paths: Vec::new(), + packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { cwd: tmp.path().to_path_buf(), org: Some(ORG.to_string()), @@ -312,6 +313,7 @@ async fn gem_crawler_finds_real_installed_gem() { let args = ScanArgs { paths: Vec::new(), + packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { cwd: tmp.path().to_path_buf(), org: Some(ORG.to_string()), diff --git a/crates/socket-patch-cli/tests/in_process_gem_multi_platform.rs b/crates/socket-patch-cli/tests/in_process_gem_multi_platform.rs index 15d7ebf9..ecf70b37 100644 --- a/crates/socket-patch-cli/tests/in_process_gem_multi_platform.rs +++ b/crates/socket-patch-cli/tests/in_process_gem_multi_platform.rs @@ -218,6 +218,7 @@ async fn mount_view( fn scan_args(cwd: &Path, api_url: String, all_releases: bool) -> ScanArgs { ScanArgs { paths: Vec::new(), + packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { cwd: cwd.to_path_buf(), org: Some(ORG.to_string()), diff --git a/crates/socket-patch-cli/tests/in_process_pypi_apply.rs b/crates/socket-patch-cli/tests/in_process_pypi_apply.rs index 91d8c7ff..8e9de4c3 100644 --- a/crates/socket-patch-cli/tests/in_process_pypi_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_pypi_apply.rs @@ -249,6 +249,7 @@ async fn pypi_install_scan_sync_patches_real_file() { let mut args = ScanArgs { paths: Vec::new(), + packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { cwd: tmp.path().to_path_buf(), org: Some(ORG.to_string()), @@ -325,6 +326,7 @@ async fn pypi_scan_then_apply_force_patches_real_file() { // 1. scan --sync to write the manifest + blob. let scan_args = ScanArgs { paths: Vec::new(), + packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { cwd: tmp.path().to_path_buf(), org: Some(ORG.to_string()), @@ -434,6 +436,7 @@ async fn pypi_apply_dry_run_does_not_modify_file() { let scan_args = ScanArgs { paths: Vec::new(), + packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { cwd: tmp.path().to_path_buf(), org: Some(ORG.to_string()), @@ -566,6 +569,7 @@ async fn pypi_crawler_finds_real_installed_six() { let args = ScanArgs { paths: Vec::new(), + packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { cwd: tmp.path().to_path_buf(), org: Some(ORG.to_string()), diff --git a/crates/socket-patch-cli/tests/in_process_pypi_multi_release.rs b/crates/socket-patch-cli/tests/in_process_pypi_multi_release.rs index 639d635e..18b866a9 100644 --- a/crates/socket-patch-cli/tests/in_process_pypi_multi_release.rs +++ b/crates/socket-patch-cli/tests/in_process_pypi_multi_release.rs @@ -292,6 +292,7 @@ async fn mount_view( fn scan_args(tmp: &Path, api_url: String, all_releases: bool) -> ScanArgs { ScanArgs { paths: Vec::new(), + packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { cwd: tmp.to_path_buf(), org: Some(ORG.to_string()), diff --git a/crates/socket-patch-cli/tests/in_process_python_envs.rs b/crates/socket-patch-cli/tests/in_process_python_envs.rs index 29e1c4ec..95325a01 100644 --- a/crates/socket-patch-cli/tests/in_process_python_envs.rs +++ b/crates/socket-patch-cli/tests/in_process_python_envs.rs @@ -115,6 +115,7 @@ async fn scan_scrubbed(args: ScanArgs) -> i32 { fn default_args(cwd: &Path, api_url: String) -> ScanArgs { ScanArgs { paths: Vec::new(), + packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { cwd: cwd.to_path_buf(), org: Some(ORG.to_string()), diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index eb77b79e..7299c758 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -44,6 +44,7 @@ const GHSA: &str = "GHSA-rdir-aaaa-bbbb"; fn redirect_args(cwd: &Path, api_url: String) -> ScanArgs { ScanArgs { paths: Vec::new(), + packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { cwd: cwd.to_path_buf(), org: Some(ORG.to_string()), diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs b/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs index a42a8f6c..da069fcb 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs @@ -75,6 +75,7 @@ fn global(cwd: &Path, api_url: String) -> GlobalArgs { fn hosted_args(cwd: &Path, api_url: String, vex: Option<&Path>) -> ScanArgs { ScanArgs { paths: Vec::new(), + packages: Vec::new(), common: global(cwd, api_url), batch_size: Some(100), apply: false, diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs b/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs index 1d01bc2d..1ebec913 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs @@ -80,6 +80,7 @@ fn global(cwd: &Path, api_url: String) -> GlobalArgs { fn hosted_args(cwd: &Path, api_url: String, vex: Option<&Path>) -> ScanArgs { ScanArgs { paths: Vec::new(), + packages: Vec::new(), common: global(cwd, api_url), batch_size: Some(100), apply: false, diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs index 3ea3791c..a3a2eb35 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs @@ -34,6 +34,7 @@ const GHSA: &str = "GHSA-rdir-pnpm-bbbb"; fn hosted_args(cwd: &Path, api_url: String) -> ScanArgs { ScanArgs { paths: Vec::new(), + packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { cwd: cwd.to_path_buf(), org: Some(ORG.to_string()), diff --git a/crates/socket-patch-cli/tests/in_process_redirect_poetry.rs b/crates/socket-patch-cli/tests/in_process_redirect_poetry.rs index 58813cb2..b26c7af3 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_poetry.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_poetry.rs @@ -59,6 +59,7 @@ fn global(cwd: &Path, api_url: String) -> GlobalArgs { fn hosted_args(cwd: &Path, api_url: String, vex: Option<&Path>) -> ScanArgs { ScanArgs { paths: Vec::new(), + packages: Vec::new(), common: global(cwd, api_url), batch_size: Some(100), apply: false, diff --git a/crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs b/crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs index 3bb1d39e..6ef4aaa6 100644 --- a/crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs @@ -73,6 +73,7 @@ async fn assert_discovered_purl(server: &MockServer, expected_purl: &str) { fn default_scan_args(cwd: &Path, eco: &str, api_url: String) -> ScanArgs { ScanArgs { paths: Vec::new(), + packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { cwd: cwd.to_path_buf(), org: Some(ORG.to_string()), diff --git a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs index b4569c12..36d3b3a0 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs @@ -63,6 +63,7 @@ const GEM_PATCH_REMOTE: &str = "http://patch.test/gems/t0k3nt0k3n/"; fn hosted_scan_args(cwd: &Path, api_url: String) -> ScanArgs { ScanArgs { paths: Vec::new(), + packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { cwd: cwd.to_path_buf(), org: Some(ORG.to_string()), diff --git a/crates/socket-patch-cli/tests/in_process_scan.rs b/crates/socket-patch-cli/tests/in_process_scan.rs index cf4bf1c4..a63d8efe 100644 --- a/crates/socket-patch-cli/tests/in_process_scan.rs +++ b/crates/socket-patch-cli/tests/in_process_scan.rs @@ -20,6 +20,7 @@ const UUID: &str = "11111111-1111-4111-8111-111111111111"; fn default_args(cwd: &Path) -> ScanArgs { ScanArgs { paths: Vec::new(), + packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { cwd: cwd.to_path_buf(), org: Some(ORG.to_string()), @@ -877,6 +878,7 @@ async fn scan_non_json_with_patches_prints_table() { let mut args = default_args(tmp.path()); args.common.api_url = Some(server.uri()); args.common.json = false; + args.mode = Some(socket_patch_cli::commands::scan::ScanMode::Agent); let code = run_scrubbed(args).await; // Non-JSON path: discovery → batch query → render table → fetch diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index f3140b4c..f9e66e87 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -3131,6 +3131,7 @@ snapshots: fn hosted_args(cwd: &Path, api_url: String) -> ScanArgs { ScanArgs { paths: Vec::new(), + packages: Vec::new(), common: GlobalArgs { cwd: cwd.to_path_buf(), org: Some(ORG.to_string()), diff --git a/crates/socket-patch-cli/tests/scan_invariants.rs b/crates/socket-patch-cli/tests/scan_invariants.rs index 2aab0020..f45d4b03 100644 --- a/crates/socket-patch-cli/tests/scan_invariants.rs +++ b/crates/socket-patch-cli/tests/scan_invariants.rs @@ -1705,7 +1705,7 @@ async fn report_only_scan_json_surfaces_hosted_redirect_state() { ); // No mode flag: the read-only discovery envelope. - let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &[]); + let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &["--mode", "agent", "--dry-run"]); assert_eq!( code, 0, "report-only scan must stay exit 0; stdout={stdout}; stderr={stderr}" @@ -1820,7 +1820,7 @@ async fn report_only_scan_json_redirect_state_splits_records_from_live_proof() { ) .unwrap(); - let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &[]); + let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &["--mode", "agent", "--dry-run"]); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); let state = &v["redirectState"]; @@ -2030,7 +2030,7 @@ async fn silent_gates_scan_malformed_ledger_warning() { std::fs::write(vendor_dir.join("redirect-state.json"), "{ torn ledger").unwrap(); // Control: without --silent the corruption is surfaced on stderr. - let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &[]); + let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &["--mode", "agent", "--dry-run"]); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); assert!( stderr.contains("malformed"), @@ -2043,7 +2043,7 @@ async fn silent_gates_scan_malformed_ledger_warning() { ); // --silent mutes the advisory warning; the run is otherwise identical. - let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &["--silent"]); + let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &["--mode", "agent", "--dry-run", "--silent"]); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); assert!( !stderr.contains("malformed"), @@ -2075,7 +2075,7 @@ async fn ecosystems_filter_keeps_records_but_not_wiring_live() { /*with_record=*/ true, ); - let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &["--ecosystems", "pypi"]); + let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &["--mode", "agent", "--dry-run", "--ecosystems", "pypi"]); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); let state = &v["redirectState"]; diff --git a/crates/socket-patch-cli/tests/telemetry_e2e.rs b/crates/socket-patch-cli/tests/telemetry_e2e.rs index cbfdb3af..9103a3bc 100644 --- a/crates/socket-patch-cli/tests/telemetry_e2e.rs +++ b/crates/socket-patch-cli/tests/telemetry_e2e.rs @@ -983,7 +983,7 @@ async fn scan_delivers_telemetry_before_writing_to_a_closed_stderr() { const TELEMETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(800); let cases: [(&str, &[&str]); 2] = [ - ("plain envelope", &[]), + ("agent preview", &["--mode", "agent", "--dry-run"]), ("vendored", &["--mode", "vendored", "--dry-run"]), ]; for (label, extra_args) in cases { From c1ee18ff987a220f72ea2ccc730aa5ca77da1074 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Sun, 27 Sep 2026 12:51:57 -0400 Subject: [PATCH 04/32] Detect hosted patch updates from the lockfile's own pins scan's updates[] now also sees the hosted pins the lockfiles wire, not only the redirect ledger's records, so a hosted project that never committed its ledger still reports a superseding patch. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/commands/scan/discovery.rs | 45 +++++++++++++------ .../socket-patch-cli/src/commands/scan/mod.rs | 15 +++++++ .../socket-patch-cli/tests/scan_invariants.rs | 4 +- 3 files changed, 49 insertions(+), 15 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs index 90b1f176..4cd7ce08 100644 --- a/crates/socket-patch-cli/src/commands/scan/discovery.rs +++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs @@ -418,21 +418,23 @@ pub(super) async fn preverify_vendor_baselines( /// CLI_CONTRACT.md) is structurally empty and a superseding patch is never /// reported. Precedence on a collision: manifest > redirect ledger > vendor /// ledger (a manifest PURL is manifest-owned, matching VEX's candidate merge -/// in `commands::vex_sources` for purls no lockfile wires to another patch). +/// in `commands::vex_sources` for purls no lockfile wires to another patch), +/// then the lockfile's hosted pins (`hosted_pins`, uuid only). /// Vendor entries are keyed by their ledger map key /// (the manifest-form purl, qualifiers included — `detect_updates` bridges /// the spellings); a legacy entry without an embedded record contributes its /// uuid alone, which is all update detection reads. Borrows the manifest -/// untouched when neither ledger contributes. Pure / no I/O so it's +/// untouched when nothing else contributes. Pure / no I/O so it's /// unit-testable. pub(super) fn merge_ledger_records_for_updates<'a>( manifest: Option<&'a PatchManifest>, redirect: Option<&socket_patch_core::patch::redirect::RedirectState>, vendor: Option<&VendorState>, + hosted_pins: &[(String, String)], ) -> Option> { let redirect_records = redirect.map(|s| &s.records).filter(|r| !r.is_empty()); let vendor_entries = vendor.map(|s| &s.entries).filter(|e| !e.is_empty()); - if redirect_records.is_none() && vendor_entries.is_none() { + if redirect_records.is_none() && vendor_entries.is_none() && hosted_pins.is_empty() { return manifest.map(Cow::Borrowed); } let mut merged = manifest.cloned().unwrap_or_default(); @@ -455,6 +457,20 @@ pub(super) fn merge_ledger_records_for_updates<'a>( }) }); } + for (purl, uuid) in hosted_pins { + merged + .patches + .entry(purl.clone()) + .or_insert_with(|| PatchRecord { + uuid: uuid.clone(), + exported_at: String::new(), + files: HashMap::new(), + vulnerabilities: HashMap::new(), + description: String::new(), + license: String::new(), + tier: String::new(), + }); + } Some(Cow::Owned(merged)) } @@ -1048,7 +1064,7 @@ mod tests { // uuid. The merged view must make detect_updates flag it — this was // structurally impossible before the fold (manifest-only detection). let ledger = ledger_with(&[("pkg:npm/foo@1.0", "uuid-old")]); - let merged = merge_ledger_records_for_updates(None, Some(&ledger), None); + let merged = merge_ledger_records_for_updates(None, Some(&ledger), None, &[]); let pkgs = vec![batch_with("pkg:npm/foo@1.0", &["uuid-new"])]; let updates = detect_updates(merged.as_deref(), &pkgs); assert_eq!(updates.len(), 1); @@ -1064,7 +1080,7 @@ mod tests { // record still contributes its uuid — all detection reads. for detached in [true, false] { let vendor = vendor_ledger_with(&[("pkg:npm/foo@1.0", "uuid-old", detached)]); - let merged = merge_ledger_records_for_updates(None, None, Some(&vendor)); + let merged = merge_ledger_records_for_updates(None, None, Some(&vendor), &[]); let pkgs = vec![batch_with("pkg:npm/foo@1.0", &["uuid-new"])]; let updates = detect_updates(merged.as_deref(), &pkgs); assert_eq!(updates.len(), 1, "detached={detached}"); @@ -1073,7 +1089,7 @@ mod tests { } // Still the top offer — no nag. let vendor = vendor_ledger_with(&[("pkg:npm/foo@1.0", "uuid-a", true)]); - let merged = merge_ledger_records_for_updates(None, None, Some(&vendor)); + let merged = merge_ledger_records_for_updates(None, None, Some(&vendor), &[]); let pkgs = vec![batch_with("pkg:npm/foo@1.0", &["uuid-a"])]; assert!(detect_updates(merged.as_deref(), &pkgs).is_empty()); } @@ -1082,7 +1098,7 @@ mod tests { fn ledger_record_matching_the_candidate_is_not_an_update() { // The redirected patch is still the top offer — no nag. let ledger = ledger_with(&[("pkg:npm/foo@1.0", "uuid-a")]); - let merged = merge_ledger_records_for_updates(None, Some(&ledger), None); + let merged = merge_ledger_records_for_updates(None, Some(&ledger), None, &[]); let pkgs = vec![batch_with("pkg:npm/foo@1.0", &["uuid-a"])]; assert!(detect_updates(merged.as_deref(), &pkgs).is_empty()); } @@ -1097,12 +1113,12 @@ mod tests { let ledger = ledger_with(&[("pkg:npm/foo@1.0", "uuid-ledger")]); let vendor = vendor_ledger_with(&[("pkg:npm/foo@1.0", "uuid-vendor", true)]); let merged = - merge_ledger_records_for_updates(Some(&manifest), Some(&ledger), Some(&vendor)); + merge_ledger_records_for_updates(Some(&manifest), Some(&ledger), Some(&vendor), &[]); let pkgs = vec![batch_with("pkg:npm/foo@1.0", &["uuid-new"])]; let updates = detect_updates(merged.as_deref(), &pkgs); assert_eq!(updates.len(), 1); assert_eq!(updates[0].old_uuid, "uuid-manifest"); - let merged = merge_ledger_records_for_updates(None, Some(&ledger), Some(&vendor)); + let merged = merge_ledger_records_for_updates(None, Some(&ledger), Some(&vendor), &[]); let updates = detect_updates(merged.as_deref(), &pkgs); assert_eq!(updates[0].old_uuid, "uuid-ledger"); } @@ -1117,7 +1133,7 @@ mod tests { let ledger = ledger_with(&[("pkg:npm/bar@2.0", "uuid-b1")]); let vendor = vendor_ledger_with(&[("pkg:npm/baz@3.0", "uuid-z1", true)]); let merged = - merge_ledger_records_for_updates(Some(&manifest), Some(&ledger), Some(&vendor)); + merge_ledger_records_for_updates(Some(&manifest), Some(&ledger), Some(&vendor), &[]); let pkgs = vec![ batch_with("pkg:npm/foo@1.0", &["uuid-f2"]), batch_with("pkg:npm/bar@2.0", &["uuid-b2"]), @@ -1133,15 +1149,18 @@ mod tests { #[test] fn absent_or_empty_ledgers_leave_the_manifest_view_untouched() { - assert!(merge_ledger_records_for_updates(None, None, None).is_none()); + assert!(merge_ledger_records_for_updates(None, None, None, &[]).is_none()); + let pins = vec![("pkg:npm/foo@1.0.0".to_string(), "uuid-pin".to_string())]; + let merged = merge_ledger_records_for_updates(None, None, None, &pins).expect("pinned"); + assert_eq!(merged.patches["pkg:npm/foo@1.0.0"].uuid, "uuid-pin"); let empty = socket_patch_core::patch::redirect::RedirectState::new(); let empty_vendor = VendorState::new(); assert!( - merge_ledger_records_for_updates(None, Some(&empty), Some(&empty_vendor)).is_none() + merge_ledger_records_for_updates(None, Some(&empty), Some(&empty_vendor), &[]).is_none() ); let manifest = crate::commands::scan::tests::manifest_with(&[("pkg:npm/foo@1.0", "uuid-a")]); - let merged = merge_ledger_records_for_updates(Some(&manifest), Some(&empty), None) + let merged = merge_ledger_records_for_updates(Some(&manifest), Some(&empty), None, &[]) .expect("manifest present"); assert!( matches!(merged, Cow::Borrowed(_)), diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index be784d8b..bba25d03 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -2373,10 +2373,25 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { } } }; + // The hosted pins the lockfiles wire count too: the lockfile is the + // record of a hosted redirect even where no ledger was committed. + let hosted_pins: Vec<(String, String)> = + if args.common.global || args.common.global_prefix.is_some() { + Vec::new() + } else { + crate::commands::discover_wiring(&args.common, &args.common.cwd) + .await + .refs + .into_iter() + .filter(|r| r.mode == socket_patch_core::vex::discover::WiringMode::Hosted) + .map(|r| (r.purl, r.uuid)) + .collect() + }; let update_manifest = merge_ledger_records_for_updates( existing_manifest.as_ref(), redirect_state.as_ref(), vendor_state.as_ref().ok(), + &hosted_pins, ); let updates = detect_updates(update_manifest.as_deref(), &all_packages_with_patches); diff --git a/crates/socket-patch-cli/tests/scan_invariants.rs b/crates/socket-patch-cli/tests/scan_invariants.rs index f45d4b03..e7f5e61e 100644 --- a/crates/socket-patch-cli/tests/scan_invariants.rs +++ b/crates/socket-patch-cli/tests/scan_invariants.rs @@ -1704,8 +1704,8 @@ async fn report_only_scan_json_surfaces_hosted_redirect_state() { /*with_record=*/ true, ); - // No mode flag: the read-only discovery envelope. - let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &["--mode", "agent", "--dry-run"]); + // A path-scoped scan with no mode: the read-only discovery envelope. + let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &["node_modules"]); assert_eq!( code, 0, "report-only scan must stay exit 0; stdout={stdout}; stderr={stderr}" From da1ccc5ecec2cf9c0bdd00a96723bab7f9ab150f Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Sun, 27 Sep 2026 13:03:21 -0400 Subject: [PATCH 05/32] Share the crawler-options and ecosystem-scope helpers across commands GlobalArgs gains crawler_options(), is_global(), ecosystem_selected() and purl_ecosystem_selected(). They replace eight hand-copied CrawlerOptions literals, seven --ecosystems predicates and seven global checks across scan, vendor, vex, apply, setup, rollback, get and repair. vendor's predicate also matched case-insensitively and accepted for golang. clap validates the names first, so neither form ever reached it. It now uses the same exact match as everything else. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/src/args.rs | 34 +++++++++++++++ crates/socket-patch-cli/src/commands/apply.rs | 16 ++------ crates/socket-patch-cli/src/commands/get.rs | 22 +++------- .../src/commands/repair_vendor.rs | 7 +--- .../socket-patch-cli/src/commands/rollback.rs | 6 +-- .../src/commands/scan/discovery.rs | 4 +- .../src/commands/scan/hosted/python.rs | 10 ++--- .../socket-patch-cli/src/commands/scan/mod.rs | 41 +++++-------------- crates/socket-patch-cli/src/commands/setup.rs | 6 +-- .../socket-patch-cli/src/commands/vendor.rs | 39 ++++++------------ .../src/commands/vex_consumed.rs | 6 +-- .../src/ecosystem_dispatch.rs | 6 +-- 12 files changed, 76 insertions(+), 121 deletions(-) diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index 499cf69f..f8820a9b 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -382,6 +382,40 @@ pub struct GlobalArgs { } impl GlobalArgs { + /// The crawler options this run's `--cwd` / `--global` / + /// `--global-prefix` select. + pub(crate) fn crawler_options(&self) -> socket_patch_core::crawlers::CrawlerOptions { + socket_patch_core::crawlers::CrawlerOptions { + cwd: self.cwd.clone(), + global: self.global, + global_prefix: self.global_prefix.clone(), + } + } + + /// Whether this run targets globally installed packages (`--global` or + /// `--global-prefix`) rather than the project at `--cwd`. + pub(crate) fn is_global(&self) -> bool { + self.global || self.global_prefix.is_some() + } + + /// Whether `--ecosystems` selects `eco` (every ecosystem when unset or + /// empty). The names are validated at parse time, so this is an exact + /// match. + pub(crate) fn ecosystem_selected(&self, eco: Ecosystem) -> bool { + self.ecosystems.as_ref().is_none_or(|list| { + list.is_empty() || list.iter().any(|name| name == eco.cli_name()) + }) + } + + /// [`Self::ecosystem_selected`] for the ecosystem of `purl`; a purl of + /// no known ecosystem is selected only when `--ecosystems` is unset. + pub(crate) fn purl_ecosystem_selected(&self, purl: &str) -> bool { + match Ecosystem::from_purl(purl) { + Some(eco) => self.ecosystem_selected(eco), + None => self.ecosystems.as_ref().is_none_or(Vec::is_empty), + } + } + /// Resolve `manifest_path` against `cwd`: absolute paths are returned /// as-is, relative paths are joined to `cwd`. pub(crate) fn resolved_manifest_path(&self) -> PathBuf { diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 4b40c6f4..0e14f686 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -3,7 +3,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, CrawlerOptions, Ecosystem, NpmPkgManager, RubyCrawler, + detect_npm_pkg_manager, Ecosystem, NpmPkgManager, RubyCrawler, }; use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::manifest::schema::{PatchFileInfo, PatchManifest, PatchRecord}; @@ -386,13 +386,7 @@ pub(crate) fn is_local_go(purl: &str, common: &GlobalArgs) -> bool { /// `.yarn/cache/*.zip`, so a run that never crawls the checkout's /// `node_modules` must not be refused by its layout). fn eco_in_local_scope(common: &GlobalArgs, eco: Ecosystem) -> bool { - if common.global || common.global_prefix.is_some() { - return false; - } - match &common.ecosystems { - None => true, - Some(list) => list.iter().any(|e| e == eco.cli_name()), - } + !common.is_global() && common.ecosystem_selected(eco) } /// Materialise a local-go redirect for `purl`, or `None` if `purl` isn't a @@ -1727,11 +1721,7 @@ async fn apply_patches_inner( let (mut results, mut matched_manifest_purls, vendored_bases) = synthesize_vendor_owned_results(&target_manifest_purls, &vendored_purls); - let crawler_options = CrawlerOptions { - cwd: args.common.cwd.clone(), - global: args.common.global, - global_prefix: args.common.global_prefix.clone(), - }; + let crawler_options = args.common.crawler_options(); // Gem bundle-store discovery, re-run cheaply (filesystem probes only, // no `gem env` shell-out) against the same ambient environment the diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index ca54e1cb..9797fc63 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -1265,14 +1265,6 @@ pub struct DownloadRun<'a> { pub verbose: bool, } -fn crawler_options_for(common: &GlobalArgs) -> CrawlerOptions { - CrawlerOptions { - cwd: common.cwd.clone(), - global: common.global, - global_prefix: common.global_prefix.clone(), - } -} - /// Narrow a selection of patches down to the release variant(s) present /// in each locally-installed distribution. /// @@ -1615,7 +1607,7 @@ async fn filter_to_installed_purls( .collect() }; let partitioned = partition_purls(&bases, None); - let found = find_packages_for_rollback(&partitioned, &crawler_options_for(common), true).await; + let found = find_packages_for_rollback(&partitioned, &common.crawler_options(), true).await; let mut present: HashSet = found.keys().map(|k| canon(k)).collect(); // Manifest membership counts as presence (read-only probe: a corrupt @@ -1901,11 +1893,7 @@ async fn lock_text_refusals_for( .map(|sr| (sr.purl.as_str(), sr.uuid.as_str())) .collect(); let refused = socket_patch_core::vendor::lock_text_refusals(cwd, &candidates).await; - let options = CrawlerOptions { - cwd: params.cwd.clone(), - global: params.global, - global_prefix: params.global_prefix.clone(), - }; + let options = params.crawler_options(); crate::commands::vendor::lock_refusals_reaching_backend( cwd, refused, @@ -2956,7 +2944,7 @@ pub async fn run(args: GetArgs) -> i32 { IdentifierType::Package => { status.set("Enumerating packages..."); let (all_packages, _, _) = - crawl_all_ecosystems(&crawler_options_for(&args.common)).await; + crawl_all_ecosystems(&args.common.crawler_options()).await; if all_packages.is_empty() { status.finish(); @@ -3198,7 +3186,7 @@ pub async fn run(args: GetArgs) -> i32 { let (selected, variant_warnings, _views) = filter_to_installed_releases( &selected, args.all_releases, - &crawler_options_for(&args.common), + &args.common.crawler_options(), quiet, &api_client, ) @@ -3237,7 +3225,7 @@ pub async fn run(args: GetArgs) -> i32 { let (selected, variant_warnings, _views) = filter_to_installed_releases( &selected, args.all_releases, - &crawler_options_for(&args.common), + &args.common.crawler_options(), quiet, &api_client, ) diff --git a/crates/socket-patch-cli/src/commands/repair_vendor.rs b/crates/socket-patch-cli/src/commands/repair_vendor.rs index 1123c5be..52f0b331 100644 --- a/crates/socket-patch-cli/src/commands/repair_vendor.rs +++ b/crates/socket-patch-cli/src/commands/repair_vendor.rs @@ -53,7 +53,6 @@ use std::path::{Path, PathBuf}; use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient}; use socket_patch_core::constants::SOCKET_DIR; -use socket_patch_core::crawlers::CrawlerOptions; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::patch::copy_tree::remove_tree; use socket_patch_core::utils::fs::read_regular_to_string; @@ -1360,11 +1359,7 @@ pub(crate) async fn repair_vendored_artifacts_with_references( // ── Pristine package sources ───────────────────────────────────────── let purls: Vec = candidates.iter().map(|c| c.purl.clone()).collect(); let partitioned = partition_purls(&purls, common.ecosystems.as_deref()); - let crawler_options = CrawlerOptions { - cwd: common.cwd.clone(), - global: common.global, - global_prefix: common.global_prefix.clone(), - }; + let crawler_options = common.crawler_options(); // Ledger keys are the manifest spelling — QUALIFIED for release-variant // ecosystems (gem `?platform=`, pypi `?artifact_id=`, maven // `?classifier=&ext=`) — while the crawler knows only base purls. A diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index d4f79371..d0b79f17 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -2348,11 +2348,7 @@ pub(crate) async fn rollback_patches_inner( .patches .retain(|purl, _| in_scope.contains(purl)); - let crawler_options = CrawlerOptions { - cwd: common.cwd.clone(), - global: common.global, - global_prefix: common.global_prefix.clone(), - }; + let crawler_options = common.crawler_options(); // Multi-copy aware: npm nests genuine duplicates of one `name@version`, // so the resolver returns EVERY physical copy per PURL. Restoring only diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs index 4cd7ce08..8546cb6e 100644 --- a/crates/socket-patch-cli/src/commands/scan/discovery.rs +++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs @@ -102,7 +102,7 @@ pub(super) async fn lockfile_supplement( use socket_patch_core::vendor::lock_inventory; let mut out = LockfileSupplement::default(); - if common.global || common.global_prefix.is_some() { + if common.is_global() { return out; } let (entries, unsupported) = lock_inventory::inventory_project_diagnosed(&common.cwd).await; @@ -175,7 +175,7 @@ pub(super) async fn vendored_ledger_supplement( crawled: &[socket_patch_core::crawlers::types::CrawledPackage], state: &std::io::Result, ) -> Vec { - if common.global || common.global_prefix.is_some() { + if common.is_global() { return Vec::new(); } let base_purls: Vec = match state { diff --git a/crates/socket-patch-cli/src/commands/scan/hosted/python.rs b/crates/socket-patch-cli/src/commands/scan/hosted/python.rs index d24956c7..8f0eeef9 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted/python.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted/python.rs @@ -2,7 +2,7 @@ use std::collections::{BTreeMap, BTreeSet}; -use socket_patch_core::crawlers::{types::CrawlerOptions, PythonCrawler}; +use socket_patch_core::crawlers::PythonCrawler; use socket_patch_core::manifest::schema::PatchRecord; use socket_patch_core::utils::purl::strip_purl_qualifiers; use socket_patch_core::vex::verify::judge_installed_record; @@ -44,13 +44,9 @@ pub(super) async fn stale_install_warnings( // copy of the release (a tool venv on PATH) and warn about a venv the // project's installer never touches — a false positive that also fails // the same-run --vex. --global / --global-prefix keep their meaning. - let paths = if common.global || common.global_prefix.is_some() { + let paths = if common.is_global() { crawler - .get_site_packages_paths(&CrawlerOptions { - cwd: common.cwd.clone(), - global: common.global, - global_prefix: common.global_prefix.clone(), - }) + .get_site_packages_paths(&common.crawler_options()) .await .unwrap_or_default() } else { diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index bba25d03..048fd84f 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -14,7 +14,7 @@ use socket_patch_core::api::client::{ }; use socket_patch_core::api::types::{BatchPackagePatches, BatchSearchResponse, PatchSearchResult}; use socket_patch_core::crawlers::ruby_crawler::config_path_ignored_warning; -use socket_patch_core::crawlers::{CrawlerOptions, Ecosystem}; +use socket_patch_core::crawlers::Ecosystem; use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::manifest::schema::PatchManifest; use socket_patch_core::telemetry::{ @@ -261,7 +261,7 @@ pub fn resolve_mode_flags(args: &mut ScanArgs) -> Result<(), String> { )); } if args.mode == Some(ScanMode::Hosted) - && (args.common.global || args.common.global_prefix.is_some()) + && args.common.is_global() { // Global installs have no project lockfile to repoint: the hosted // flow would "redirect 0 packages" and exit 0, a silent no-op. @@ -1744,13 +1744,9 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { // how often stale-token fallbacks fire in the wild. let mut fallback_to_proxy = false; - let crawler_options = CrawlerOptions { - cwd: args.common.cwd.clone(), - global: args.common.global, - global_prefix: args.common.global_prefix.clone(), - }; + let crawler_options = args.common.crawler_options(); - let scan_target = if args.common.global || args.common.global_prefix.is_some() { + let scan_target = if args.common.is_global() { "global packages" } else { "packages" @@ -1805,12 +1801,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { // gated stderr line on the human path) unless `--ecosystems` filtered // gem out of this run. if let Some(value) = skipped_bundle_config_path { - if args - .common - .ecosystems - .as_ref() - .is_none_or(|list| list.iter().any(|e| e == Ecosystem::Gem.cli_name())) - { + if args.common.ecosystem_selected(Ecosystem::Gem) { let (code, detail) = config_path_ignored_warning(&value); layout_refusals.push((code.to_string(), detail)); } @@ -1872,20 +1863,10 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { .unwrap_or_default(); // Filter by --ecosystems if provided - let filtered_crawled: Vec<_> = if let Some(ref allowed) = args.common.ecosystems { - all_crawled - .into_iter() - .filter(|pkg| { - if let Some(eco) = Ecosystem::from_purl(&pkg.purl) { - allowed.iter().any(|a| a == eco.cli_name()) - } else { - false - } - }) - .collect() - } else { - all_crawled - }; + let filtered_crawled: Vec<_> = all_crawled + .into_iter() + .filter(|pkg| args.common.purl_ecosystem_selected(&pkg.purl)) + .collect(); let filtered_crawled: Vec<_> = if args.packages.is_empty() { filtered_crawled @@ -2054,7 +2035,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { println!( "{}", render::no_packages_message( - args.common.global || args.common.global_prefix.is_some(), + args.common.is_global(), args.common.ecosystems.as_deref(), &args.paths, ) @@ -2376,7 +2357,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { // The hosted pins the lockfiles wire count too: the lockfile is the // record of a hosted redirect even where no ledger was committed. let hosted_pins: Vec<(String, String)> = - if args.common.global || args.common.global_prefix.is_some() { + if args.common.is_global() { Vec::new() } else { crate::commands::discover_wiring(&args.common, &args.common.cwd) diff --git a/crates/socket-patch-cli/src/commands/setup.rs b/crates/socket-patch-cli/src/commands/setup.rs index c530616a..722262f7 100644 --- a/crates/socket-patch-cli/src/commands/setup.rs +++ b/crates/socket-patch-cli/src/commands/setup.rs @@ -417,11 +417,7 @@ fn pathdiff(path: &str, base: &Path) -> String { /// value parser admits no alias or case variant — and it is the same rule /// `partition_purls` applies, so setup's scope never diverges from apply's. fn eco_in_scope(common: &GlobalArgs, eco: Ecosystem) -> bool { - match &common.ecosystems { - None => true, - Some(list) if list.is_empty() => true, - Some(list) => list.iter().any(|e| e == eco.cli_name()), - } + common.ecosystem_selected(eco) } /// Normalize a workspace-member / exclude path for comparison: trimmed, diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 31478624..4618fcdd 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -346,11 +346,12 @@ fn orphan_label(unit: &vendor::path::SweptVendorDir) -> String { /// Does `eco` fall inside this run's `--ecosystems` scope? pub(crate) fn ecosystem_in_scope(common: &GlobalArgs, eco: &str) -> bool { - match common.ecosystems.as_deref() { - None => true, - Some(list) => list.iter().any(|e| { - e.eq_ignore_ascii_case(eco) || (eco == "golang" && e.eq_ignore_ascii_case("go")) - }), + match socket_patch_core::crawlers::Ecosystem::all() + .iter() + .find(|e| e.cli_name() == eco) + { + Some(eco) => common.ecosystem_selected(*eco), + None => common.ecosystems.as_ref().is_none_or(Vec::is_empty), } } @@ -1832,11 +1833,7 @@ pub(crate) async fn vendor_records_reusing( vendor::prestage::sweep_stale(&common.cwd).await; } - let crawler_options = CrawlerOptions { - cwd: common.cwd.clone(), - global: common.global, - global_prefix: common.global_prefix.clone(), - }; + let crawler_options = common.crawler_options(); // Resolve installed packages with the qualified-purl-aware resolver, never // a base-keyed one: the manifest keys release-variant ecosystems (gem // `?platform=`, pypi `?artifact_id=`, maven `?classifier=&ext=`) by @@ -5158,11 +5155,11 @@ mod scope_and_hint_tests { } } - /// The `Some(list)` branch of [`ecosystem_in_scope`]: exact match, - /// case-insensitivity, and the `go` → `golang` alias; `None` means - /// everything is in scope. + /// [`ecosystem_in_scope`] is `--ecosystems`' exact-name match (clap + /// validates the names, so no case or alias variant reaches it); `None` + /// means everything is in scope. #[test] - fn ecosystem_in_scope_honors_list_alias_and_case() { + fn ecosystem_in_scope_is_an_exact_name_match() { let unscoped = with_scope(None); assert!(ecosystem_in_scope(&unscoped, "npm")); assert!(ecosystem_in_scope(&unscoped, "cargo")); @@ -5172,18 +5169,8 @@ mod scope_and_hint_tests { assert!(!ecosystem_in_scope(&npm_only, "cargo")); assert!(!ecosystem_in_scope(&npm_only, "golang")); - let upper = with_scope(Some(&["NPM"])); - assert!( - ecosystem_in_scope(&upper, "npm"), - "scope matching is case-insensitive" - ); - - let go_alias = with_scope(Some(&["go"])); - assert!( - ecosystem_in_scope(&go_alias, "golang"), - "`go` must alias the golang ecosystem" - ); - assert!(!ecosystem_in_scope(&go_alias, "npm")); + let golang = with_scope(Some(&["golang"])); + assert!(ecosystem_in_scope(&golang, "golang")); } } diff --git a/crates/socket-patch-cli/src/commands/vex_consumed.rs b/crates/socket-patch-cli/src/commands/vex_consumed.rs index b8914f0f..5500e53e 100644 --- a/crates/socket-patch-cli/src/commands/vex_consumed.rs +++ b/crates/socket-patch-cli/src/commands/vex_consumed.rs @@ -76,11 +76,7 @@ pub(crate) async fn hosted_consumed_copies( if hosted.is_empty() { return out; } - let options = CrawlerOptions { - cwd: common.cwd.clone(), - global: common.global, - global_prefix: common.global_prefix.clone(), - }; + let options = common.crawler_options(); let purls: Vec = hosted.keys().cloned().collect(); let partitioned = partition_purls(&purls, common.ecosystems.as_deref()); diff --git a/crates/socket-patch-cli/src/ecosystem_dispatch.rs b/crates/socket-patch-cli/src/ecosystem_dispatch.rs index ec2071ce..9be0519b 100644 --- a/crates/socket-patch-cli/src/ecosystem_dispatch.rs +++ b/crates/socket-patch-cli/src/ecosystem_dispatch.rs @@ -632,11 +632,7 @@ pub async fn find_manifest_package_copies_reusing( prior: Option<&NpmCrawlSnapshot>, ) -> HashMap> { let partitioned = partition_purls(purls, common.ecosystems.as_deref()); - let crawler_options = CrawlerOptions { - cwd: common.cwd.clone(), - global: common.global, - global_prefix: common.global_prefix.clone(), - }; + let crawler_options = common.crawler_options(); let npm_roots = prior.and_then(|p| p.roots_for(&crawler_options)); dispatch_find( &partitioned, From 62f07c780c95cead2067a12e5ca73af0dd7e4c01 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Sun, 27 Sep 2026 13:36:44 -0400 Subject: [PATCH 06/32] Let hosted and vendored scans take project directories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In hosted and vendored mode, and so in a bare scan, positional PATHs now name project directories: each directory, or directory glob such as apps/*, is scanned on its own as if it were --cwd, under a "== ==" header. The exit code is the worst of the runs. A PATH that is not a directory is a usage error. --json takes a single directory so stdout stays one document. Agent-mode PATHs keep their installed-path glob meaning. The changelog's v5 section now leads with the scan → vex → vendor workflow and describes the new scan defaults. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 69 ++++++--- .../socket-patch-cli/src/commands/scan/mod.rs | 131 ++++++++++++++---- .../socket-patch-cli/tests/cli_parse_scan.rs | 7 +- .../tests/covgap_commands_scan_mod.rs | 45 ++++-- .../socket-patch-cli/tests/scan_invariants.rs | 4 +- .../socket-patch-cli/tests/scan_paths_e2e.rs | 27 ++-- 6 files changed, 215 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c905badf..2e853cce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,12 +17,22 @@ into the new version's section — see docs/releasing.md. ## [Unreleased] +> **v5 at a glance.** The CLI is built around one workflow: `socket-patch +> scan` patches dependencies by rewriting lockfiles so only the patched +> packages resolve to Socket-hosted, integrity-pinned copies (hosted mode is +> the default, and scan never prompts); `socket-patch vex` emits OpenVEX for +> vulnerability scanners; `socket-patch vendor` ejects the patches into +> `.socket/vendor/` for offline installs; `socket-patch list` shows them. +> `get`, `apply`, `setup`, `rollback`, `remove` and `repair` (the agent-mode +> commands) keep working and are listed after these. + > **Semver note:** this entry changes `rollback`'s default behavior, narrows > the meaning of its existing `vendored: []` JSON key, makes vendored mode > manifest-free, moves vendored cargo wiring from `.cargo/config*` into > `Cargo.toml`, tags vendored cargo copies' versions with `+socket.` -> (visible to the patched crate as `CARGO_PKG_VERSION`), turns a plain -> non-TTY `scan` report-only, makes `vex` +> (visible to the patched crate as `CARGO_PKG_VERSION`), makes a bare `scan` +> run hosted mode without prompting, changes which patch scan picks when a +> package has several, makes `vex` > refuse to attest stale ledger records and corrupt vendor ledgers, and > retries a throttled patch API (new error text, added waiting, a throttled > package failing its legacy-proxy batch) — all @@ -233,27 +243,32 @@ into the new version's section — see docs/releasing.md. with no manifest is a clean exit-0 no-op whose message names the missing manifest (and the ledger entries `repair` verifies) instead of claiming "No .socket folder found". -- **A plain `scan` without a TTY is report-only.** When stdin is not a TTY, - `--yes` is absent, and no intent flag (`--mode`, `--apply`, `--sync`, - `--vendor`, `--redirect`, `--prune`) is given, human-mode `scan` prints the - discovery report and the "To apply a single patch, run: …" hint, downloads - nothing, creates no `.socket/`, and exits 0 — it no longer auto-accepts the - apply prompt. Any intent flag, `--yes`, or a TTY keeps the previous - behavior; `rollback`/`remove`/`get`'s non-TTY auto-accept is unchanged. - Human `scan --mode hosted` now prints the results table and update - detection like the other modes and confirms once ("Redirect N packages - to the hosted patch server?" — the same prompt as `get --mode hosted` — - default yes, skipped by `--yes`/`--json`/`--dry-run`; on a non-TTY stdin - without `--yes` it prints `Non-interactive mode detected, proceeding - automatically.` and proceeds), fetches patch details with the agent arm's - progress counter and per-package warnings, and an empty hosted discovery - prints `No patches available for installed packages.` and exits 0 without - entering the redirect engine (was `Redirected 0 package(s)`); a discovery +- **A bare `scan` runs hosted mode and never prompts.** With no `--mode` + (or legacy mode flag), `scan` rewrites lockfiles so the patched + dependencies resolve to Socket-hosted packages, exactly like + `scan --mode hosted`; under `--json` its result nests under `redirect` as + before. Only a `--prune` or `--global` scan with no mode is still + report-only (neither has a project lockfile to rewire); it prints the + report and `To apply these patches in place, run: socket-patch scan + --mode agent [PATHS]`. `scan` asks nothing in any mode: the download and + redirect confirmations and the free-tier patch menu are gone (and with + them the `Non-interactive mode detected` note), so `--yes` no longer + changes what `scan` does. Human `scan --mode hosted` prints the results + table and update detection like the other modes, fetches patch details + with the agent arm's progress counter and per-package warnings, and an + empty hosted discovery prints `No patches available for installed + packages.` and exits 0 without entering the redirect engine; a discovery whose every offer is paid-tier for an org without paid access stops the same way with `No downloadable patches (paid subscription required).`. A malformed redirect ledger on a human hosted run that stops before the engine is reported as the read-only `Warning: the redirect ledger … is malformed` advisory instead of nowhere. +- **scan picks the newest merged patch.** When a package has several + patches, `scan` (and `get`, and the `[UPDATE]` detection) now takes the + newest merged patch (one that fixes several advisories in one blob — the + package's cumulative fix) the account can download, whatever its + severity. A package with no merged patch keeps the old order: highest + severity, then newest. - **`apply.lock` never outlives a command, and hosted mode takes it.** Lock acquisition creates `.socket/` when missing; the lock file is unlinked (while still held) and an otherwise-empty `.socket/` removed when the @@ -338,6 +353,16 @@ into the new version's section — see docs/releasing.md. ### Added +- **`scan --package `** (repeatable or comma-separated, env + `SOCKET_SCAN_PACKAGES`) scopes a scan to the named packages: a name + (`lodash`, `@scope/pkg`, `group:artifact`) or a purl with or without its + version (`pkg:npm/lodash`, `pkg:pypi/requests@2.31.0`); names compare + case-insensitively. +- **Hosted projects report patch updates from their lockfiles.** scan's + `updates[]` and `[UPDATE]` marker also see the hosted pins the lockfiles + wire, so a hosted project that never committed its redirect ledger still + reports a superseding patch. + - **`apply` and `rollback` patch vlt installs in place.** A project installed by vlt (`node_modules/.vlt/` or `node_modules/.vlt-lock.json`) is detected as vlt ahead of any sibling bun, pnpm, yarn or npm marker, @@ -843,8 +868,12 @@ into the new version's section — see docs/releasing.md. prune universe is never narrowed (`scan PATHS --prune` prunes exactly what an unscoped run would), lockfile-only/vendor-ledger supplements are excluded with a `path_scope_excluded_supplements` warning, an empty match - is a normal empty scan (exit 0, no GC), and PATHS is rejected with - `--mode hosted|vendored` (exit 2). `rollback [TARGET]...` accepts + is a normal empty scan (exit 0, no GC). In hosted and vendored mode + (so also a bare `scan`) PATHS name project directories instead: each + directory or directory glob (`apps/*`) is scanned on its own, as if it + were `--cwd`, under a `== ==` header; the exit code is the worst of + the runs, a PATH that is not a directory is a usage error (exit 2), and + `--json` takes one directory. `rollback [TARGET]...` accepts PURLs, UUIDs, and path globs (variadic, unioned); only path-SHAPED tokens (separator, glob metachar, `./` prefix, absolute) become globs, so a mistyped identifier stays a safe exit-1 error. A path target selecting diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 048fd84f..ae48f665 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -26,7 +26,7 @@ use socket_patch_core::vendor::VendorState; use socket_patch_core::vex::discover::{LedgerLiveness, WiringMode}; use std::collections::{HashMap, HashSet}; use std::io::IsTerminal; -use std::path::Path; +use std::path::{Path, PathBuf}; use crate::args::{apply_env_toggles, GlobalArgs}; use crate::commands::vex::{generate_vex_from_manifest_path, VexEmbedArgs}; @@ -239,27 +239,11 @@ pub fn resolve_mode_flags(args: &mut ScanArgs) -> Result<(), String> { args.mode = Some(ScanMode::Vendored); } else if args.apply || args.sync { args.mode = Some(ScanMode::Agent); - } else if args.paths.is_empty() - && !args.prune - && !args.common.global - && args.common.global_prefix.is_none() - { - // v5: hosted is the default. A path-scoped, `--prune` or global scan - // with no mode stays report-only (none of them can rewire lockfiles). + } else if !args.prune && !args.common.is_global() { + // v5: hosted is the default. A `--prune` or global scan with no mode + // stays report-only (neither has a project lockfile to rewire). args.mode = Some(ScanMode::Hosted); } - if !args.paths.is_empty() - && matches!(args.mode, Some(ScanMode::Hosted) | Some(ScanMode::Vendored)) - { - // Hosted/vendored rewire the project's root lockfiles — whole-project - // by construction — so path scoping cannot mean anything coherent - // there. Same phrasing family as the conflicts above. - return Err(format!( - "path targeting cannot be used with --mode {}: it applies to \ - agent-mode and read-only scans", - args.mode.expect("checked Some above").cli_name(), - )); - } if args.mode == Some(ScanMode::Hosted) && args.common.is_global() { @@ -286,15 +270,14 @@ pub fn resolve_mode_flags(args: &mut ScanArgs) -> Result<(), String> { Ok(()) } -#[derive(Args)] +#[derive(Args, Clone)] pub struct ScanArgs { - /// Only scan packages installed under these path globs (e.g. - /// `packages/foo`, `apps/**`; a bare directory scopes its whole - /// subtree). `--prune` still considers the whole project, so a scoped - /// scan never prunes out-of-scope manifest entries. Lockfile-only - /// packages have no installed path and are left out (with a warning). - /// Not available with `--mode hosted` or `--mode vendored`, which - /// rewire the whole project + /// Only scan these directories. In hosted and vendored mode each PATH + /// (or glob, e.g. `apps/*`) is a project directory, scanned on its own + /// as if it were `--cwd`. In agent mode PATHs are globs over installed + /// package paths (a bare directory scopes its whole subtree; `--prune` + /// still considers the whole project, and lockfile-only packages are + /// left out with a warning) pub paths: Vec, #[command(flatten)] @@ -1656,6 +1639,64 @@ pub async fn run(args: ScanArgs) -> i32 { code } +/// The project directories a hosted or vendored scan's PATHs name: each +/// PATH is a directory, or a glob matching directories, relative to +/// `--cwd`. Sorted and deduplicated. +fn project_dirs(cwd: &Path, paths: &[String]) -> Result, String> { + let mut dirs: Vec = Vec::new(); + for raw in paths { + 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 before = dirs.len(); + dirs.extend(matches.filter_map(Result::ok).filter(|p| p.is_dir())); + if dirs.len() == before { + return Err(format!("`{raw}` matches no directory")); + } + } else if joined.is_dir() { + dirs.push(joined); + } else { + return Err(format!("`{raw}` is not a directory")); + } + } + dirs.sort(); + dirs.dedup(); + Ok(dirs) +} + +/// Run a hosted or vendored scan once per project directory its PATHs +/// name, as if each were `--cwd`. The exit code is the worst of the runs. +/// `--json` takes one directory, so stdout stays one document. +async fn run_project_dirs(args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { + let dirs = match project_dirs(&args.common.cwd, &args.paths) { + Ok(dirs) => dirs, + Err(message) => { + eprintln!("Error: {message}"); + return 2; + } + }; + if args.common.json && dirs.len() > 1 { + eprintln!( + "Error: --json takes one project directory ({} given); run one scan per directory", + dirs.len() + ); + return 2; + } + let mut code = 0; + for dir in &dirs { + if dirs.len() > 1 && !args.common.silent { + let shown = dir.strip_prefix(&args.common.cwd).unwrap_or(dir); + println!("\n== {} ==", shown.display()); + } + let mut child = args.clone(); + child.paths.clear(); + child.common.cwd = dir.clone(); + code = code.max(Box::pin(run_scan(child, telemetry)).await); + } + code +} + async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { apply_env_toggles(&args.common); @@ -1673,6 +1714,14 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { return 2; } + // Hosted and vendored modes rewire a project's lockfiles, so their + // PATHs name project directories: one scan per directory. + if matches!(args.mode, Some(ScanMode::Hosted) | Some(ScanMode::Vendored)) + && !args.paths.is_empty() + { + return Box::pin(run_project_dirs(args, telemetry)).await; + } + // Positional PATH globs (see `ScanArgs::paths`). An unparseable glob // is a usage error, same exit-2 shape as the mode conflicts. let path_scope = match crate::path_scope::PathScope::parse(&args.paths) { @@ -3226,6 +3275,32 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { mod tests { use super::*; + #[test] + fn project_dirs_resolve_directories_and_globs() { + let tmp = tempfile::tempdir().unwrap(); + for d in ["apps/web", "apps/api", "libs/core"] { + std::fs::create_dir_all(tmp.path().join(d)).unwrap(); + } + std::fs::write(tmp.path().join("apps/README"), "").unwrap(); + let rel = |dirs: Vec| -> Vec { + dirs.iter() + .map(|d| d.strip_prefix(tmp.path()).unwrap().to_string_lossy().replace('\\', "/")) + .collect() + }; + let got = project_dirs(tmp.path(), &["apps/*".into(), "libs/core".into(), "apps/web".into()]) + .unwrap(); + assert_eq!(rel(got), ["apps/api", "apps/web", "libs/core"]); + assert!(project_dirs(tmp.path(), &["apps/README".into()]) + .unwrap_err() + .contains("is not a directory")); + assert!(project_dirs(tmp.path(), &["nope/*".into()]) + .unwrap_err() + .contains("matches no directory")); + assert!(project_dirs(tmp.path(), &["x[".into()]) + .unwrap_err() + .contains("invalid path pattern")); + } + #[test] fn package_specs_match_names_and_purls() { let lodash = "pkg:npm/lodash@4.17.20"; diff --git a/crates/socket-patch-cli/tests/cli_parse_scan.rs b/crates/socket-patch-cli/tests/cli_parse_scan.rs index ff27865a..06863f78 100644 --- a/crates/socket-patch-cli/tests/cli_parse_scan.rs +++ b/crates/socket-patch-cli/tests/cli_parse_scan.rs @@ -623,8 +623,11 @@ fn mode_agent_is_the_source_of_truth() { // v5: no mode selected means hosted... let folded = parse_and_resolve(&[]).expect("fold ok"); assert_eq!(folded.mode, Some(ScanMode::Hosted), "a bare scan is hosted"); - // ...except where no lockfile can be rewired: those stay report-only. - for argv in [&["--prune"][..], &["packages/foo"], &["--global"]] { + // PATHs name hosted project directories. + let folded = parse_and_resolve(&["packages/foo"]).expect("fold ok"); + assert_eq!(folded.mode, Some(ScanMode::Hosted)); + // No project lockfile to rewire: these stay report-only. + for argv in [&["--prune"][..], &["--global"]] { let folded = parse_and_resolve(argv).expect("fold ok"); assert_eq!(folded.mode, None, "{argv:?} stays report-only"); } diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs index a82258b2..dde9044b 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs @@ -1274,15 +1274,15 @@ async fn scan_human_empty_batch_reports_no_patches_once() { // Non-TTY human scans: the mode-less scan is report-only, explicit intent // auto-proceeds // --------------------------------------------------------------------------- -// v5: scan never prompts. A bare scan runs hosted mode; a path-scoped, -// `--prune` or global scan with no mode only reports (it has no lockfile -// to rewire), and `--mode agent` applies in place without asking. +// v5: scan never prompts. A bare scan runs hosted mode; a `--prune` or +// global scan with no mode only reports (it has no lockfile to rewire), +// and `--mode agent` applies in place without asking. -/// A path-scoped scan with no mode: the discovery, table and per-patch -/// preview print (the report IS the value), then the run stops — no view -/// fetch, no `.socket/`, the installed file untouched. +/// `--prune` with no mode: the discovery, table and per-patch preview +/// print (the report IS the value), then the run stops — no view fetch, no +/// `.socket/`, the installed file untouched. #[tokio::test] -async fn scan_path_scoped_human_without_a_mode_is_report_only() { +async fn scan_prune_without_a_mode_is_report_only() { let mock = MockServer::start().await; let purl = "pkg:npm/minimist@1.2.2"; mount_one_patch_api(&mock, purl, b"x\n").await; @@ -1291,7 +1291,7 @@ async fn scan_path_scoped_human_without_a_mode_is_report_only() { write_root_package_json(tmp.path()); write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); - let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &["node_modules"]); + let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &["--prune"]); assert_eq!( code, 0, "report-only is a success; stdout={stdout}; stderr={stderr}" @@ -1479,6 +1479,35 @@ fn seed_redirect_ledger(root: &Path, purl: &str, uuid: &str) { .unwrap(); } +/// Hosted PATHs name project directories: each runs as its own scan +/// (its own discovery and redirect), under a `== ==` header. +#[tokio::test] +async fn scan_hosted_paths_run_once_per_project_directory() { + let mock = MockServer::start().await; + let purl = "pkg:npm/minimist@1.2.2"; + mount_batch_one(&mock, purl, UUID, "free", &[], false).await; + mount_by_package(&mock, purl, UUID, serde_json::json!({})).await; + mount_forbidden_reference(&mock, purl).await; + + let tmp = tempfile::tempdir().unwrap(); + for app in ["apps/a", "apps/b"] { + let dir = tmp.path().join(app); + std::fs::create_dir_all(&dir).unwrap(); + write_root_package_json(&dir); + write_npm_package(&dir, "minimist", "1.2.2", b"x\n"); + } + + let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &["apps/*"]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + for app in ["apps/a", "apps/b"] { + let header = format!("== {} ==", std::path::Path::new(app).display()); + assert!(stdout.contains(&header), "missing {header:?}: {stdout}"); + } + assert_eq!(stdout.matches("Redirected 0 packages").count(), 2, "{stdout}"); + let reqs = recorded(&mock).await; + assert_eq!(batch_bodies(&reqs).len(), 2, "one discovery per directory"); +} + #[tokio::test] async fn scan_hosted_human_prints_table_updates_and_redirects() { let mock = MockServer::start().await; diff --git a/crates/socket-patch-cli/tests/scan_invariants.rs b/crates/socket-patch-cli/tests/scan_invariants.rs index e7f5e61e..22fd29e2 100644 --- a/crates/socket-patch-cli/tests/scan_invariants.rs +++ b/crates/socket-patch-cli/tests/scan_invariants.rs @@ -1704,8 +1704,8 @@ async fn report_only_scan_json_surfaces_hosted_redirect_state() { /*with_record=*/ true, ); - // A path-scoped scan with no mode: the read-only discovery envelope. - let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &["node_modules"]); + // `--prune` with no mode: the read-only discovery envelope. + let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &["--prune"]); assert_eq!( code, 0, "report-only scan must stay exit 0; stdout={stdout}; stderr={stderr}" diff --git a/crates/socket-patch-cli/tests/scan_paths_e2e.rs b/crates/socket-patch-cli/tests/scan_paths_e2e.rs index 954c9ec3..d338ccf8 100644 --- a/crates/socket-patch-cli/tests/scan_paths_e2e.rs +++ b/crates/socket-patch-cli/tests/scan_paths_e2e.rs @@ -213,7 +213,7 @@ async fn paths_scope_narrows_the_query() { let tmp = tempfile::tempdir().unwrap(); write_two_subtree_project(tmp.path()); - let (code, stdout, stderr) = run_scan(tmp.path(), &server.uri(), &["packages/app"]); + let (code, stdout, stderr) = run_scan(tmp.path(), &server.uri(), &["packages/app", "--mode", "agent", "--dry-run"]); assert_eq!( code, 0, "scoped scan must exit 0; stdout={stdout}; stderr={stderr}" @@ -474,7 +474,7 @@ async fn supplements_excluded_with_warning() { // purl reaches the API. let scoped_server = MockServer::start().await; mock_batch_empty(&scoped_server).await; - let (code, stdout, stderr) = run_scan(tmp.path(), &scoped_server.uri(), &["packages/app"]); + let (code, stdout, stderr) = run_scan(tmp.path(), &scoped_server.uri(), &["packages/app", "--mode", "agent", "--dry-run"]); assert_eq!( code, 0, "scoped scan must exit 0; stdout={stdout}; stderr={stderr}" @@ -540,12 +540,14 @@ async fn supplements_excluded_with_warning() { // --------------------------------------------------------------------------- #[tokio::test] -async fn paths_with_hosted_or_vendored_mode_exit_2() { - // All three refusals fire before any network I/O, so the unreachable - // API URL doubles as the no-network oracle (a connect attempt would +async fn paths_with_hosted_or_vendored_mode_name_project_directories() { + // Every refusal fires before any network I/O, so the unreachable API + // URL doubles as the no-network oracle (a connect attempt would // surface as a different error, not the usage message). let tmp = tempfile::tempdir().unwrap(); write_root_package_json(tmp.path()); + std::fs::create_dir_all(tmp.path().join("apps/a")).unwrap(); + std::fs::create_dir_all(tmp.path().join("apps/b")).unwrap(); for mode in ["hosted", "vendored"] { let (code, stdout, stderr) = run_scan( @@ -555,12 +557,12 @@ async fn paths_with_hosted_or_vendored_mode_exit_2() { ); assert_eq!( code, 2, - "PATHS + --mode {mode} must be a usage error (exit 2); \ + "a PATH that is not a directory is a usage error (exit 2) under --mode {mode}; \ stdout={stdout}; stderr={stderr}" ); assert!( - stderr.contains("path targeting"), - "--mode {mode} refusal must name path targeting; stderr={stderr}" + stderr.contains("`packages/app` is not a directory"), + "stderr={stderr}" ); assert!( stdout.trim().is_empty(), @@ -568,6 +570,15 @@ async fn paths_with_hosted_or_vendored_mode_exit_2() { ); } + // --json keeps stdout one document: one project directory only. + let (code, stdout, stderr) = run_scan(tmp.path(), "http://127.0.0.1:1", &["apps/*"]); + assert_eq!(code, 2, "stdout={stdout}; stderr={stderr}"); + assert!( + stderr.contains("--json takes one project directory (2 given)"), + "stderr={stderr}" + ); + assert!(stdout.trim().is_empty(), "stdout={stdout}"); + // An unparseable glob is the same exit-2 usage-error shape. let (code, stdout, stderr) = run_scan(tmp.path(), "http://127.0.0.1:1", &["x["]); assert_eq!( From 60918330d5a8b43b259cd8aab9d6d860163149cf Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Sun, 27 Sep 2026 16:43:12 -0400 Subject: [PATCH 07/32] Rank [UPDATE] detection by the same rule scan installs by candidate_supersedes still put severity above merge state, so scan's updates[] and [UPDATE] marker could name a different patch from the one scan selects. It now calls a new ranking::batch_supersedes, which uses the same merged-first key as the selection and ignores the tier and uuid tiebreaks and missing dates. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/commands/scan/discovery.rs | 282 ++++++------------ crates/socket-patch-core/src/api/ranking.rs | 17 ++ 2 files changed, 101 insertions(+), 198 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs index 8546cb6e..d9d51ea7 100644 --- a/crates/socket-patch-cli/src/commands/scan/discovery.rs +++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs @@ -39,12 +39,9 @@ pub(super) struct LockfileSupplement { /// packages included), kept so the hosted-wiring probes reuse it instead /// of re-parsing every project lockfile. Empty for global scans. pub(super) entries: Vec, - /// npm layouts the lockfile inventory REFUSED (Plug'n'Play loaders) — - /// packages structurally unreachable, as opposed to nothing-to-inventory. - /// Scan surfaces these as explicit refusal warnings: under yarn PnP the - /// installed-tree crawl is also empty (no `node_modules/`), so without - /// this channel a PnP project scans as a silent success-0 no-op in - /// every mode. + /// npm layouts the lockfile inventory REFUSED (Plug'n'Play loaders). + /// Scan surfaces these as refusal warnings: under PnP the installed-tree + /// crawl is empty too, so otherwise the project scans as a silent no-op. pub(super) unsupported: Vec, } @@ -184,16 +181,10 @@ pub(super) async fn vendored_ledger_supplement( .values() .map(|entry| strip_purl_qualifiers(&entry.base_purl).to_string()) .collect(), - // Corrupt/unreadable ledger (a MISSING file is Ok(empty) above). - // Returning empty here silently dropped every vendored purl from - // `scanned_purls` — and since the purl-keys prune - // exemption degrades to empty on the same Err (fail-open by its - // documented contract), `scan --prune` then deleted still-vendored - // packages' manifest entries and blobs while their committed - // artifacts remained. Recover the vendored set from the committed - // ground truth instead: a manifest entry whose patch uuid owns a - // live `.socket/vendor//` artifact dir is vendored (the - // contract-documented recovery convention — see `vendor::path`). + // Corrupt/unreadable ledger (a MISSING file is Ok(empty) above): + // recover the vendored set from the committed artifacts, or + // `scan --prune` (whose ledger exemption also degrades to empty) + // would delete still-vendored packages' manifest entries and blobs. Err(_) => vendored_purls_from_artifacts(common).await, }; let crawled_norm: HashSet = crawled @@ -258,12 +249,12 @@ async fn vendored_purls_from_artifacts(common: &GlobalArgs) -> Vec { out } -/// Vendor-mode pre-prompt check: uuids of selected patches whose installed +/// Vendor-mode pre-flight check: uuids of selected patches whose installed /// files match NEITHER beforeHash nor afterHash — the patch was built /// against different bytes than the installed artifact. Vendoring still /// succeeds for these (the vendor stage force-applies the verified patched -/// content; see `force_apply_staged`), but the user should learn it BEFORE -/// the confirm prompt, not from a post-hoc warning event. +/// content; see `force_apply_staged`), but the user learns it before +/// vendoring starts rather than from a post-hoc warning event. /// /// Returns `(mismatched uuids, fetched views by uuid)`: the download phase /// serves its records from the views instead of fetching each one a second @@ -272,12 +263,10 @@ async fn vendored_purls_from_artifacts(common: &GlobalArgs) -> Vec { /// /// `vendor` is the run's ledger (`None` when unreadable — fail-open, the /// preflight reports the corruption): a purl the ledger already holds -/// detached at the selected uuid with an embedded record — exactly the -/// entries the download phase reuses without a view fetch — is compared +/// detached at the selected uuid with an embedded record is compared /// against that record's file hashes instead of fetching the view, so an -/// idempotent re-run performs zero view fetches in the human arm too -/// (contract: "same-uuid re-runs reuse the embedded record, skip the -/// patch-view fetch"). Nothing is inserted into `views` for them. +/// idempotent re-run performs zero view fetches. Nothing is inserted into +/// `views` for them. /// /// Best-effort and read-only: a detail-fetch failure or an unresolvable /// installed path just skips the annotation — it never blocks the flow and @@ -410,22 +399,16 @@ pub(super) async fn preverify_vendor_baselines( } /// Fold both ledgers' patch records into the manifest view update detection -/// consults. Hosted mode persists its purl→uuid records ONLY in -/// `.socket/vendor/redirect-state.json`, and vendored mode ONLY in -/// `.socket/vendor/state.json` (each entry embeds its patch `record`) — -/// neither writes `.socket/manifest.json` — so without this fold a pure -/// hosted or vendored project's `updates[]` (the documented CI signal, see -/// CLI_CONTRACT.md) is structurally empty and a superseding patch is never -/// reported. Precedence on a collision: manifest > redirect ledger > vendor -/// ledger (a manifest PURL is manifest-owned, matching VEX's candidate merge -/// in `commands::vex_sources` for purls no lockfile wires to another patch), -/// then the lockfile's hosted pins (`hosted_pins`, uuid only). -/// Vendor entries are keyed by their ledger map key -/// (the manifest-form purl, qualifiers included — `detect_updates` bridges -/// the spellings); a legacy entry without an embedded record contributes its -/// uuid alone, which is all update detection reads. Borrows the manifest -/// untouched when nothing else contributes. Pure / no I/O so it's -/// unit-testable. +/// consults. Hosted mode records purl→uuid ONLY in +/// `.socket/vendor/redirect-state.json` and vendored mode ONLY in +/// `.socket/vendor/state.json`, so without this fold a pure hosted or +/// vendored project's `updates[]` would always be empty. Precedence on a +/// collision: manifest > redirect ledger > vendor ledger (matching VEX's +/// candidate merge in `commands::vex_sources`), then the lockfile's hosted +/// pins (`hosted_pins`, uuid only). Vendor entries are keyed by their +/// manifest-form ledger key (`detect_updates` bridges the spellings); a +/// legacy entry without an embedded record contributes its uuid alone. +/// Borrows the manifest untouched when nothing else contributes. pub(super) fn merge_ledger_records_for_updates<'a>( manifest: Option<&'a PatchManifest>, redirect: Option<&socket_patch_core::patch::redirect::RedirectState>, @@ -488,42 +471,24 @@ pub(super) fn detect_updates( let mut updates = Vec::new(); for pkg in packages { // The candidate is the top-ranked patch — the one the apply path - // resolves to. Both sides rank with `api::ranking`, so the - // `[UPDATE]` marker and the JSON `updates` array track what - // `--apply` installs. - // - // Caveat, and the one place the two can still disagree: we rank - // BATCH-shaped patches here, while apply ranks the richer - // by-package shape. The batch response currently omits - // `publishedAt`, so when a package's top candidates tie on merge - // status AND severity, this falls through to the UUID tiebreak - // while apply correctly uses the date. `BatchPatchInfo` already - // deserializes `publishedAt` when present, so the divergence - // disappears the moment the endpoint emits it — no client change. - // (Verified live on pkg:npm/axios@1.6.0, two free HIGH patches.) + // resolves to, so `[UPDATE]` and `updates[]` track what scan + // installs. One divergence: the batch response omits `publishedAt`, + // so on a merge-status + severity tie this falls to the tier/uuid + // tiebreaks where apply (by-package shape) uses the date. // - // `ApiClient` already returns each package's patches best-first, so - // `min_by` here is a cheap guard rather than a correction — but it - // is load-bearing for callers that build a `BatchPackagePatches` - // themselves rather than getting one from the client. + // `min_by` is load-bearing for callers that build a + // `BatchPackagePatches` themselves (the client already sorts). let Some(candidate) = pkg.patches.iter().min_by(|a, b| cmp_batch_infos(a, b)) else { continue; }; - // Manifest keys are written verbatim from the *patch* purl, which - // the API serves percent-encoded (`pkg:npm/%40scope/...`) and, for - // artifact-pinned ecosystems, qualified (`?artifact_id=...`); the - // batch *package* purl is the crawler's literal spelling. Bridge - // both divergences like the lockfile-only partition does: exact hit - // first, then a normalized qualifier-stripped comparison. + // Manifest keys are the API's *patch* purl (percent-encoded and, + // for artifact-pinned ecosystems, qualified); the batch *package* + // purl is the crawler's literal spelling. Exact hit first, then a + // normalized qualifier-stripped comparison. // - // Qualifier TWINS (one package recorded under two artifact-pinned - // keys, e.g. a pypi wheel + sdist pair) both match the stripped - // comparison. `manifest.patches` is a HashMap, so a bare `find` - // would pick a per-process-random twin; instead: any stale twin - // means an update is available, so prefer the first twin (in - // sorted-key order, for run-to-run stability) whose uuid differs - // from the candidate, and fall back to the first twin when all - // agree. + // Qualifier TWINS (e.g. a pypi wheel + sdist pair) all match the + // stripped form: any stale twin means an update, so prefer the + // first (sorted-key order, for stability) whose uuid differs. let existing = manifest.patches.get(&pkg.purl).or_else(|| { let want = normalize_purl(strip_purl_qualifiers(&pkg.purl)); let mut twins: Vec<(&String, &socket_patch_core::manifest::schema::PatchRecord)> = @@ -546,22 +511,10 @@ pub(super) fn detect_updates( if candidate.uuid == existing.uuid { continue; } - // (b) The candidate out*ranks* the recorded patch, but "outranks" - // includes the pure tier/uuid tiebreaks and — because the batch - // endpoint routinely omits `publishedAt` — an epoch-0 date that is - // NOT real evidence of recency. When the recorded patch is still - // among the offered patches, `cmp_batch_infos` can crown an - // equal-or-older sibling as the "top" candidate purely on the uuid - // tiebreak, which used to nag a vendored project forever with a patch - // no newer than the one already committed. Only surface an update - // when the candidate GENUINELY supersedes the applied patch on a - // meaningful axis (severity, merge coverage, or a real, - // strictly-greater publish date). - // - // If the recorded patch is no longer offered at all, we cannot - // compare ages; a different, currently-available candidate is the - // best signal we have, so flag it (this is also the only behavior a - // manifest-only, no-batch record can produce). + // (b) "Outranks" includes the tier/uuid tiebreaks and the batch + // endpoint's missing (epoch-0) dates, so when the recorded patch is + // still offered, only report a candidate that genuinely supersedes + // it. If it is no longer offered, any different candidate is flagged. if let Some(applied) = pkg.patches.iter().find(|p| p.uuid == existing.uuid) { if !candidate_supersedes(candidate, applied) { continue; @@ -576,59 +529,11 @@ pub(super) fn detect_updates( updates } -/// Whether `candidate` genuinely supersedes the already-applied `applied` -/// patch — strictly better on a MEANINGFUL ranking axis (severity, merge -/// coverage, or a real, strictly-greater publish date), never on the pure -/// tier/uuid tiebreaks or an absent-date (epoch-0) artifact. -/// -/// This is the guard that kills the false `[UPDATE]` nag. Batch responses -/// omit `publishedAt`, so [`cmp_batch_infos`] falls through to the uuid -/// tiebreak and can rank an equal-or-older sibling above the applied patch; -/// flagging that as an update perpetually nags a vendored project. Both -/// patches are batch-shaped and drawn from the SAME package response, so this -/// compares like with like, mirroring `api::ranking::rank_batch_info`. +/// Whether `candidate` genuinely supersedes the applied patch (see +/// [`socket_patch_core::api::ranking::batch_supersedes`]): the guard that +/// keeps an equal sibling from showing as a perpetual `[UPDATE]`. fn candidate_supersedes(candidate: &BatchPatchInfo, applied: &BatchPatchInfo) -> bool { - use socket_patch_core::api::date::parse_timestamp_secs; - use socket_patch_core::api::ranking::{merged_coverage, severity_order}; - - // Advisory count = inferred merge state: prefer GHSA ids, fall back to - // CVE ids only when no GHSA is named (so CVE aliases can't inflate it). - let advisories = |p: &BatchPatchInfo| { - if p.ghsa_ids.is_empty() { - p.cve_ids.len() - } else { - p.ghsa_ids.len() - } - }; - - // Severity: lower rank number = worse vulnerability. A candidate fixing a - // strictly worse advisory supersedes; a less-severe one never does. - let cand_sev = severity_order(candidate.severity.as_deref()); - let applied_sev = severity_order(applied.severity.as_deref()); - if cand_sev != applied_sev { - return cand_sev < applied_sev; - } - - // Merge coverage: a patch folding in more advisories is broader. - let cand_cov = merged_coverage(advisories(candidate)); - let applied_cov = merged_coverage(advisories(applied)); - if cand_cov != applied_cov { - return cand_cov > applied_cov; - } - - // Recency: only a REAL, strictly-greater publishedAt counts. A missing - // date (the batch norm) parses to `None` and is NOT treated as newer, so - // an equal-or-older sibling is never surfaced as an update. Parsing stays - // on the RFC-2822-aware `api::date` helper. - let cand_date = candidate - .published_at - .as_deref() - .and_then(parse_timestamp_secs); - let applied_date = applied - .published_at - .as_deref() - .and_then(parse_timestamp_secs); - matches!((cand_date, applied_date), (Some(c), Some(a)) if c > a) + socket_patch_core::api::ranking::batch_supersedes(candidate, applied) } /// The scan table's VULNERABILITIES data for one package, built from the @@ -727,12 +632,8 @@ mod tests { #[test] fn severity_order_moderate_is_medium_tier() { - // Regression: GHSA emits `moderate` for the medium tier, and scan - // passes raw API severities straight through. get.rs - // `severity_rank`, `ui::severity`, and core's - // `get_severity_order` all map it to medium; ranking it 4 here - // (= unknown, below `low`) made the table's max-severity column - // show `low` for a package whose worst vuln is moderate. + // GHSA emits `moderate` for the medium tier and scan passes raw API + // severities through, so it must rank as medium, not unknown. assert_eq!(severity_order("moderate"), severity_order("medium")); assert!(severity_order("moderate") < severity_order("low")); assert_eq!(severity_order("Moderate"), severity_order("medium")); @@ -829,8 +730,7 @@ mod tests { fn detect_updates_bridges_qualified_manifest_keys() { // Manifest keys for artifact-pinned ecosystems carry qualifiers // (`?artifact_id=...`); the batch purl is bare. The stripped-purl - // bridge must match them — decode-only would silently drop these - // packages from `updates[]` again. + // bridge must match them, or these packages drop out of `updates[]`. let m = manifest_with(&[("pkg:pypi/foo@1.0?artifact_id=foo-1.0.tar.gz", "uuid-a")]); let pkgs = vec![batch_with("pkg:pypi/foo@1.0", &["uuid-b"])]; let updates = detect_updates(Some(&m), &pkgs); @@ -934,13 +834,8 @@ mod tests { #[test] fn detect_updates_no_update_when_manifest_holds_candidate_despite_other_patches() { - // Regression: the human-readable table once flagged `[UPDATE]` (and - // bumped `updates_available`) whenever *any* batch patch differed from - // the manifest UUID. But the apply path resolves to the top-ranked - // patch, so a manifest already holding that candidate is up to date - // even when the batch also lists lesser patches. The table and the - // JSON `updates` array must agree; both derive from this function, - // which compares the ranked candidate only. + // A manifest already holding the top-ranked candidate is up to date + // even when the batch also lists lesser patches. let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-critical")]); let pkgs = vec![batch_ranked( "pkg:npm/foo@1.0", @@ -958,15 +853,9 @@ mod tests { #[test] fn detect_updates_no_nag_when_applied_patch_still_offered_and_batch_omits_dates() { - // Regression (false-update-nag-batch-ranking / -older-uuid): after - // vendoring, the batch endpoint re-lists BOTH the applied patch and a - // sibling and OMITS `publishedAt`. With no real date, `cmp_batch_infos` - // collapses to the uuid tiebreak and crowns whichever sibling sorts - // first. `uuid-a` sorts before the applied `uuid-b`, so it becomes the - // ranked candidate — but it is no genuine improvement (same severity, - // same coverage, no newer date), so it must NOT be surfaced as an - // update. Before the fix this flagged a perpetual `[UPDATE]` pointing - // at an equal-or-older patch. + // The batch re-lists the applied patch and a sibling with no + // `publishedAt`, so `uuid-a` wins only on the uuid tiebreak. That is + // no genuine improvement, so it must NOT be surfaced as an update. let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-b")]); let pkgs = vec![batch_with("pkg:npm/foo@1.0", &["uuid-a", "uuid-b"])]; assert!( @@ -1029,8 +918,7 @@ mod tests { } /// A vendor ledger with one entry per `(key, uuid, detached)`: detached - /// entries embed their record (the D2 posture), legacy ones carry only - /// the uuid. + /// entries embed their record, legacy ones carry only the uuid. fn vendor_ledger_with(entries: &[(&str, &str, bool)]) -> VendorState { let entries: serde_json::Map = entries .iter() @@ -1061,8 +949,7 @@ mod tests { fn ledger_only_project_reports_superseding_patch_in_updates() { // Pure hosted project: NO .socket/manifest.json, one redirected patch // recorded in the ledger; discovery now offers a different (newer) - // uuid. The merged view must make detect_updates flag it — this was - // structurally impossible before the fold (manifest-only detection). + // uuid. The merged view must make detect_updates flag it. let ledger = ledger_with(&[("pkg:npm/foo@1.0", "uuid-old")]); let merged = merge_ledger_records_for_updates(None, Some(&ledger), None, &[]); let pkgs = vec![batch_with("pkg:npm/foo@1.0", &["uuid-new"])]; @@ -1075,7 +962,7 @@ mod tests { #[test] fn vendored_only_project_reports_superseding_patch_in_updates() { - // Pure vendored project (manifest-free, D2): the ledger entry's + // Pure vendored project (manifest-free): the ledger entry's // embedded record is the "old" side. A legacy entry with no embedded // record still contributes its uuid — all detection reads. for detached in [true, false] { @@ -1174,16 +1061,9 @@ mod tests { } // ---- vendored_ledger_supplement (corrupt-ledger fallback) --------------- - // The prune-safety chain for vendored packages: their purls enter - // `scanned_purls` via this supplement, which shields their manifest - // entries (and blobs) from `scan --prune`'s GC even when the - // `VendorState::purl_keys` exemption degrades to empty (fail-open by its - // documented contract). A corrupt `.socket/vendor/state.json` - // (`load_state` → Err; a MISSING file is Ok(empty)) must therefore fall - // back to the committed ground truth — manifest entries whose patch uuid - // owns a live `.socket/vendor//` artifact dir — instead of - // silently returning empty and letting the prune delete still-vendored - // records. + // Vendored purls enter `scanned_purls` via this supplement, which shields + // their manifest entries from `scan --prune`. A corrupt ledger must fall + // back to the committed artifact dirs rather than return empty. const VENDORED_UUID: &str = "11111111-1111-4111-8111-111111111111"; @@ -1551,12 +1431,10 @@ mod tests { assert_ne!(rewritten[0].1, "probe detail text"); } - // ---- candidate_supersedes (merge-coverage rung) ---------------------- - // Production publishes no merged patches yet, so this rung has never run - // outside these tests; these pin its polarity for the day one ships. + // ---- candidate_supersedes (merge rung) --------------------------------- /// A batch-shaped patch with explicit advisory lists and NO publish - /// date, so only the severity and merge-coverage rungs can decide. + /// date, so only the merge and severity rungs can decide. fn info_with_advisories( uuid: &str, severity: Option<&str>, @@ -1578,7 +1456,7 @@ mod tests { #[test] fn candidate_supersedes_on_broader_ghsa_merge_coverage() { // Same severity, no dates: only the advisory count separates them. - // A patch folding in MORE GHSAs is broader and genuinely supersedes. + // A merged patch (>= 2 GHSAs) genuinely supersedes a single one. let merged = info_with_advisories( "uuid-merged", Some("high"), @@ -1591,22 +1469,34 @@ mod tests { candidate_supersedes(&merged, &single), "broader merge coverage is a genuine supersede" ); - // Swapped: a NARROWER candidate never supersedes. Only reachable by - // direct call — via detect_updates a lower-coverage candidate can - // never win `min_by` — but the polarity of the `>` at the coverage - // return must be pinned somewhere. + // Swapped: an unmerged candidate never supersedes a merged one. assert!( !candidate_supersedes(&single, &merged), "narrower coverage must never supersede" ); } + #[test] + fn a_merged_candidate_supersedes_a_more_severe_single_patch() { + // Same rule as the selection ranking: merged beats severity, so the + // [UPDATE] marker names the patch scan would install. + let merged = info_with_advisories( + "uuid-merged", + Some("low"), + &["GHSA-1111-1111-1111", "GHSA-2222-2222-2222"], + &[], + ); + let critical = + info_with_advisories("uuid-crit", Some("critical"), &["GHSA-3333-3333-3333"], &[]); + assert!(candidate_supersedes(&merged, &critical)); + assert!(!candidate_supersedes(&critical, &merged)); + } + #[test] fn candidate_supersedes_cve_aliases_do_not_inflate_ghsa_coverage() { // Both sides name a GHSA, so the CVE lists are aliases and must not - // count: 1 == 1 advisory, no date on either side -> not a supersede - // in either direction (falls through coverage to the strict-date - // rung, which requires two REAL dates). + // count: both unmerged, same severity, no dates -> not a supersede + // in either direction (the date rung requires two REAL dates). let candidate = info_with_advisories( "uuid-cand", Some("high"), @@ -1628,8 +1518,7 @@ mod tests { // End-to-end through detect_updates: the manifest holds the // single-advisory patch; the batch offers it alongside a merged // sibling (2 GHSAs, same severity, no dates). The merged patch wins - // the ranking on coverage AND genuinely supersedes — the module doc - // promises this works the day production ships a merged patch. + // the ranking AND genuinely supersedes. let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-single")]); let pkgs = vec![BatchPackagePatches { purl: "pkg:npm/foo@1.0".to_string(), @@ -1710,12 +1599,9 @@ mod tests { search_result("uuid-ghost", "pkg:npm/ghost@1.0.0"), ]; let crawled = vec![ - // The lockonly purl HAS a crawled counterpart — production - // passes `filtered_crawled`, which CONTAINS the fabricated - // lockfile-only supplement entries — so the lockfile-only guard - // is the deciding branch: were it (or its normalize bridge) - // broken, the find below would succeed and the detail fetch - // would fire, tripping the request-log assertion. + // The lockonly purl HAS a crawled counterpart (production's crawl + // includes the fabricated lockfile-only entries), so the + // lockfile-only guard is the deciding branch. crawled_pkg( "lockonly", "pkg:npm/@scope/lockonly@1.0.0", diff --git a/crates/socket-patch-core/src/api/ranking.rs b/crates/socket-patch-core/src/api/ranking.rs index 0e828ff7..395f790d 100644 --- a/crates/socket-patch-core/src/api/ranking.rs +++ b/crates/socket-patch-core/src/api/ranking.rs @@ -153,6 +153,23 @@ pub fn cmp_search_results(a: &PatchSearchResult, b: &PatchSearchResult) -> Order rank_search_result(a).cmp(&rank_search_result(b)) } +/// Whether `candidate` is strictly better than `applied` on a meaningful +/// rung of the ranking: merged state, severity (between unmerged patches), +/// or a real, strictly later publish date. The paid-tier and uuid tiebreaks +/// never count, and neither does a missing date (the batch endpoint omits +/// `publishedAt`), so an equal sibling is never reported as an update. +pub fn batch_supersedes(candidate: &BatchPatchInfo, applied: &BatchPatchInfo) -> bool { + let (c, a) = (rank_batch_info(candidate), rank_batch_info(applied)); + if c.not_merged != a.not_merged { + return !c.not_merged; + } + if c.severity != a.severity { + return c.severity < a.severity; + } + let (Reverse(c_date), Reverse(a_date)) = (c.patch_published, a.patch_published); + c_date > 0 && a_date > 0 && c_date > a_date +} + /// Compare two batch-shaped patches best-first. Pass straight to `sort_by`. /// /// Same key as [`cmp_search_results`], but the batch shape carries a From 5e5f5ed9a4a5e136f800ca6a459f43255a98b910 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Sun, 27 Sep 2026 16:43:12 -0400 Subject: [PATCH 08/32] Default get to hosted mode, like scan socket-patch get , and the bare-UUID shortcut, now redirect the package to its Socket-hosted patched copy. Agent mode (manifest + in-place apply) is --mode agent. It stays the default with --save-only, which records a manifest entry, and with --global/--global-prefix, since those have no project lockfile to rewrite. The agent-mode test fixtures now pass --mode agent. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/src/commands/get.rs | 194 +++++++++--------- crates/socket-patch-cli/src/lib.rs | 10 +- .../tests/cli_get_silent_errors.rs | 18 +- .../tests/covgap_commands_get.rs | 31 ++- .../tests/get_nested_apply_api_flags_e2e.rs | 8 +- .../tests/in_process_get_manifest_path.rs | 4 +- .../tests/in_process_get_modes.rs | 2 +- 7 files changed, 130 insertions(+), 137 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 9797fc63..76fe2a8b 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -71,10 +71,8 @@ pub(crate) enum PatchAction { /// /// A non-zero exit code must ALWAYS pair with a non-`success` status: /// both are derived from the same predicate here so a JSON consumer -/// reading `status` and a shell reading `$?` can never disagree. The -/// historical bug was a `status` of `success` (keyed only on download -/// failures) sitting next to an exit code of `1` produced by a failed -/// *apply* step. +/// reading `status` and a shell reading `$?` can never disagree (a failed +/// *apply* step must not report `success`). fn run_outcome(patches_failed: bool, apply_failed: bool) -> (&'static str, i32) { if patches_failed || apply_failed { ("partial_failure", 1) @@ -438,10 +436,9 @@ fn files_with_both_hashes(patch: &PatchResponse) -> HashMap` and /// `scan`/`apply`/`vendor` all record and write the same set of files. -/// The previous both-hashes-only rule silently dropped every added file, -/// e.g. the whole-crate cargo export where ALL files lack a `beforeHash` -/// (recorded `files:{}` → reported `applied:1` while writing nothing) and -/// a gem patch's genuinely-new runtime-guard file. +/// A both-hashes rule here would drop every added file (e.g. a whole-crate +/// cargo export where ALL files lack a `beforeHash`, recorded as `files:{}` +/// while reporting `applied:1`). fn files_for_manifest(patch: &PatchResponse) -> HashMap { let mut files = HashMap::new(); for (file_path, file_info) in &patch.files { @@ -495,8 +492,7 @@ pub struct GetArgs { // `value_parser = parse_bool_flag` matches the `GlobalArgs` bool flags: // clap's default bool parser accepts only the literal strings // `true`/`false` from the env binding, so `SOCKET_SAVE_ONLY=1` (or an - // exported-but-empty `SOCKET_SAVE_ONLY=`) aborted every `get` - // invocation. + // exported-but-empty `SOCKET_SAVE_ONLY=`) would abort every `get`. #[arg( long = "save-only", alias = "no-apply", @@ -511,9 +507,9 @@ pub struct GetArgs { // Hidden: it always fails with "not yet implemented" (see `run`), but // stays parseable so scripts and `SOCKET_ONE_OFF` keep getting that // explicit error instead of a clap parse failure. - // `value_parser = parse_bool_flag`: same env-crash fix as `--save-only` - // above — and `SOCKET_ONE_OFF` is shared with `rollback --one-off`, - // which already parses boolishly; the two must not diverge. + // `value_parser = parse_bool_flag`: same reason as `--save-only` above — + // and `SOCKET_ONE_OFF` is shared with `rollback --one-off`, which parses + // boolishly too; the two must not diverge. #[arg( long = "one-off", env = "SOCKET_ONE_OFF", @@ -542,7 +538,7 @@ pub struct GetArgs { pub all_releases: bool, /// How to consume the patches: the same modes as `scan --mode` - /// (default: agent). + /// [default: hosted; agent with `--save-only` or `--global`] // agent = record in .socket/manifest.json + blobs and apply in place; // hosted = rewrite lockfiles so the patched deps resolve to Socket's // hosted patch server (no manifest, no blobs; state lives in the @@ -1079,9 +1075,10 @@ fn forced_identifier_error(identifier: &str, id_type: IdentifierType) -> Option< /// Select one patch per PURL from available patches. /// /// Within a PURL, candidates are ranked by [`cmp_search_results`]: merged -/// patches first, then by severity (critical → low), then most recently -/// published. `tier` is an access filter here, not a ranking signal — a -/// free critical patch outranks a paid low one. +/// patches first (newest first, whatever their severity); other patches by +/// severity (critical → low), then most recently published. `tier` is an +/// access filter here, not a ranking signal — a free critical patch +/// outranks a paid low one. /// /// - Users with paid access: auto-select the top-ranked patch per PURL. /// - Free users with one patch, or with `--yes`: auto-select the @@ -1102,7 +1099,6 @@ pub(crate) fn select_patches( can_access_paid: bool, common: &GlobalArgs, ) -> Result, i32> { - // Group accessible patches by PURL let mut by_purl: HashMap> = HashMap::new(); for p in patches { if p.tier == "free" || can_access_paid { @@ -1126,8 +1122,8 @@ pub(crate) fn select_patches( if can_access_paid { // Take the top-ranked patch. Note this is NOT "prefer paid": - // tier only breaks ties once merge status, severity and recency - // have all tied. + // tier only breaks ties once the merged/severity/recency ranking + // (see `api::ranking`) has tied. selected.push(group[0].clone()); } else if group.len() == 1 || (common.yes && !common.json) { // One candidate, or `--yes` (which answers every prompt with its @@ -1208,19 +1204,18 @@ pub struct DownloadParams { pub silent: bool, /// `--download-mode` value forwarded to the apply step. pub download_mode: String, - /// When `false` (the default — narrow), a PyPI package with multiple - /// release variants (`?artifact_id=...`) is filtered down to the one - /// matching the locally-installed distribution before download. When - /// `true` (`--all-releases`), every variant is downloaded. No effect - /// on ecosystems without per-release artifact_id variants. + /// When `false` (the default — narrow), a release-variant package (PyPI + /// `?artifact_id=`, RubyGems `?platform=`, Maven `?classifier=`) is + /// filtered down to the variant(s) matching the locally-installed + /// distribution before download. When `true` (`--all-releases`), every + /// variant is downloaded. No effect on ecosystems without per-release + /// variants. pub all_releases: bool, /// `--strict` forwarded to the nested apply (a beforeHash mismatch /// fails instead of warn-and-overwrite). pub strict: bool, - /// `--ecosystems` forwarded to the nested apply. Without this the - /// nested apply ran UNSCOPED over the whole manifest, so - /// `scan --ecosystems gem --sync` could mutate other ecosystems' - /// packages the user had explicitly filtered out. + /// `--ecosystems` forwarded to the nested apply, so it never touches + /// other ecosystems' packages the user filtered out. pub ecosystems: Option>, /// Persist downloaded blob content into `.socket/blobs` (the apply /// flows need it for later hook/rollback runs). Vendor flows pass @@ -1346,10 +1341,9 @@ async fn filter_to_installed_releases( // `variant_groups` is a HashMap, so both drains above are in bucket // order — which is this function's OUTPUT order, and therefore the // order the download loop emits `download.patches` / `apply.patches` - // in. Two identical runs produced different JSON. Sort the multi- - // variant bases so their warnings and kept variants are stable, and - // sort the whole kept list by purl before returning (below and at the - // early return): every sibling collection in the same envelope — + // in. Sort the multi-variant bases so their warnings and kept variants + // are stable, and sort the whole kept list by purl before returning + // (below and at the early return): every sibling collection in the same envelope — // scan's `packages`, the agent flow's `skip_records` — is purl-sorted. multi.sort_by(|a, b| a.0.cmp(&b.0)); @@ -1367,7 +1361,8 @@ async fn filter_to_installed_releases( .iter() .flat_map(|(_, variants)| variants.iter().map(|s| s.purl.clone())) .collect(); - // All collected PURLs are PyPI; no ecosystem filter needed. + // Release-variant PURLs only (PyPI / RubyGems / Maven); partition_purls + // splits them by ecosystem, so no filter is needed. let partitioned = partition_purls(&all_qualified, None); let paths = find_packages_for_rollback(&partitioned, crawler_options, true).await; @@ -1375,7 +1370,7 @@ async fn filter_to_installed_releases( // `api_concurrency` in flight) in the order the loop below consumes // them: bases in `multi` order, skipping the uninstalled ones, each // base's variants in order. Nothing here prints between fetches, and - // each request's `--debug` lines are released at its old turn. + // each request's `--debug` lines are released at its turn in that order. let installed_variants: Vec = multi .iter() .filter(|(_, variants)| variants.iter().any(|s| paths.contains_key(&s.purl))) @@ -1432,7 +1427,7 @@ async fn filter_to_installed_releases( views.insert(s.uuid.clone(), patch); } // On a fetch error/miss, keep the variant so the main - // download loop can record the failure as it would today. + // download loop records the failure. _ => candidates.push((s.purl.clone(), HashMap::new())), } } @@ -1501,8 +1496,8 @@ fn purl_has_version(purl: &str) -> bool { } /// Does the raw pnpm-lock text RESOLVE `name@version`? Boundary-anchored -/// probes over the three lock grammars — a plain `contains` collided on -/// version prefixes (`left-pad@1.3.0` matched inside +/// probes over the three lock grammars — a plain `contains` collides on +/// version prefixes (`left-pad@1.3.0` matches inside /// `left-pad@1.3.0-beta.1`), name suffixes (`pad@1.3.0` inside /// `left-pad@1.3.0`), and unscoped-inside-scoped names (`name@1.0.0` inside /// `@scope/name@1.0.0`). The needles cover v6/v9's `name@version` and v5's @@ -1943,7 +1938,7 @@ async fn fetch_selected_patches( // --all-releases was passed (a no-op for non-variant ecosystems and // single-variant packages). The views it fetched serve the loop below. // The narrowing queries the API: show that something is happening - // right after the confirm prompt. + // once selection (and get's confirm prompt) is done. let mut status = crate::ui::StatusLine::stderr(params.json, params.silent); status.set("Preparing download..."); let (selected, warnings, views) = filter_to_installed_releases( @@ -1956,7 +1951,8 @@ async fn fetch_selected_patches( .await; status.finish(); prefetched.extend(views); - // No leading blank line: the prompt's answer already ended its line. + // No leading blank line: the caller's prompt or summary already ended + // its line. if matches!(store, RecordStore::Manifest(_)) && !quiet { eprintln!( "Downloading {}...", @@ -1979,10 +1975,9 @@ async fn fetch_selected_patches( // (the same three checks, in the loop's order, over inputs the loop // never mutates) — run concurrently ahead of it, at most // `api_concurrency` in flight, and come back in selection order. The - // loop takes the next one exactly where it used to await the request, - // and each request's `--debug` lines print there too, so stdout, the - // per-patch stderr lines and the JSON records fold exactly as the - // serial loop's did. + // loop takes the next one where it would await the request, and each + // request's `--debug` lines print there too, so stdout, the per-patch + // stderr lines and the JSON records fold in selection order. let mut held: std::collections::HashSet<&str> = prefetched.keys().map(String::as_str).collect(); let to_fetch: Vec<&str> = selected .iter() @@ -2228,8 +2223,8 @@ pub(crate) type DetachedDownload = ( /// /// `api_client` is the run's client (built once, proxy fallback included). /// `prefetched` maps uuid → an already-fetched view: the `get ` path -/// resolved its identifier by fetching the view, and scan's interactive -/// arm pre-verified baselines from the views — neither must fetch again (a +/// resolved its identifier by fetching the view, and scan's vendored arm +/// pre-verified baselines from the views — neither must fetch again (a /// fresh fetch could re-hit the 401 the proxy fallback just recovered /// from). The ledger idempotency check runs before the cache lookup, and a /// cache miss still fetches. @@ -2264,9 +2259,9 @@ pub(crate) async fn download_patch_records_reusing( let vendor_state = load_state(¶ms.cwd).await; // Bun preflight (see `BunVendorRefusal`): this phase feeds the vendor // engine, so it must refuse the same projects BEFORE fetching — - // otherwise the view was downloaded for nothing and a package - // resolvable only through the unreadable bun.lockb inventory - // misreported `package_not_installed` instead of the real + // otherwise the view is downloaded for nothing and a package + // resolvable only through the unreadable bun.lockb inventory is + // misreported as `package_not_installed` instead of the real // `vendor_bun_*` code. npm-only, so release narrowing (PyPI / RubyGems / // Maven variants) cannot change its verdict. let bun_refusal = bun_vendor_preflight_with_ledger( @@ -2441,10 +2436,9 @@ fn nested_apply_args_from_params( global_prefix: params.global_prefix.clone(), download_mode: params.download_mode.clone(), strict: params.strict, - // Scope the nested apply like the caller was scoped: leaving this - // at the default `None` made `scan --ecosystems gem --sync` apply - // the WHOLE manifest, mutating other ecosystems' packages the user - // filtered out. + // Scope the nested apply like the caller was scoped: `None` would + // apply the WHOLE manifest, mutating other ecosystems' packages the + // user filtered out. ecosystems: params.ecosystems.clone(), lock_timeout: run.lock_timeout, verbose: run.verbose, @@ -2460,9 +2454,9 @@ fn nested_apply_args_from_params( /// its last mutation is done. Returns whether apply exited 0. Callers print /// their own "Applying patches..." line. `json` is the caller's flag: a /// JSON caller gets no human error lines, from this function or from the -/// nested apply (`common` itself is never JSON). The read-only cargo-redirect verifier stays off -/// and embedded VEX is opt-in on the top-level command only, never on this -/// internal invocation. +/// nested apply (`common` itself is never JSON). The read-only `--check` +/// redirect verifier stays off and embedded VEX is opt-in on the top-level +/// command only, never on this internal invocation. async fn run_nested_apply( common: GlobalArgs, json: bool, @@ -2488,7 +2482,7 @@ async fn run_nested_apply( /// Download the selected patches into `.socket/` (manifest records + /// blobs) and, unless `save_only`, apply them in place — the agent-mode -/// engine behind `get` and `scan --apply/--sync`, over the caller's +/// engine behind `get` and `scan --mode agent`, over the caller's /// run-level context (`run`: the client the run already built, plus the /// `--lock-timeout` / `--verbose` the manifest lock and the nested apply /// honor). Returns `(exit_code, json)`. @@ -2504,8 +2498,8 @@ pub async fn download_and_apply_patches_with( // The manifest read-modify-write — and the blob writes it records — // runs under the apply lock: `remove`/`rollback` RMW the same file under - // it, and an unlocked writer here lost their update or had its own - // record clobbered. `acquire` creates `.socket/` itself; the guard's + // it, and an unlocked writer here would lose their update or have its + // own record clobbered. `acquire` creates `.socket/` itself; the guard's // drop removes `apply.lock` and prunes an otherwise-empty `.socket/`, so // a run that records nothing leaves no residue. The nested apply runs // under this SAME guard (one lock window; see `run_nested_apply`). @@ -2676,11 +2670,15 @@ pub async fn run(args: GetArgs) -> i32 { ); return 1; } - // Mode resolution mirrors scan's enum (default = agent, today's - // behavior). Conflicts use get's established exit-1 report_error style - // (scan's self-enforced conflicts exit 2; get's have always been 1 — - // documented carve-out in CLI_CONTRACT.md). - let mode = args.mode.unwrap_or(super::scan::ScanMode::Agent); + // v5: hosted by default, like scan. `--save-only` (records a manifest + // entry) and global installs (no project lockfile) mean agent mode. + // Conflicts use get's exit-1 report_error style (scan's self-enforced + // conflicts exit 2 — documented carve-out in CLI_CONTRACT.md). + let mode = args.mode.unwrap_or(if args.save_only || args.common.is_global() { + super::scan::ScanMode::Agent + } else { + super::scan::ScanMode::Hosted + }); if args.save_only && mode != super::scan::ScanMode::Agent { report_error( args.common.json, @@ -2694,11 +2692,10 @@ pub async fn run(args: GetArgs) -> i32 { return 1; } if args.one_off { - // Honest failure instead of the historical silent no-op: the flag - // parsed but was never implemented, so the patch was saved to the - // manifest anyway — lying to the user about persistence. Mirrors - // `rollback --one-off`'s not-yet-implemented contract; rejected - // before any network or disk activity. + // The flag parses but is not implemented: fail loudly rather than + // save to the manifest anyway. Mirrors `rollback --one-off`'s + // not-yet-implemented contract; rejected before any network or disk + // activity. report_error(args.common.json, "One-off get mode is not yet implemented"); return 1; } @@ -2839,7 +2836,7 @@ pub async fn run(args: GetArgs) -> i32 { // 401/403 the fallback just recovered from. An explicit // UUID is exempt from installed narrowing (exact intent). return match mode { - // Save to manifest and apply in place (today's flow). + // Save to manifest and apply in place. super::scan::ScanMode::Agent => { save_and_apply_patch(&args, &api_client, &patch).await } @@ -3945,10 +3942,8 @@ async fn run_get_vendored( } /// Decode a patch view's `blobContent` (canonical, padded base64 as the API -/// produces it). Hand-rolled only because `base64` is a dev-dependency of -/// this crate today — once it is a plain dependency (it already is one of -/// `socket-patch-core`, pinned workspace-wide), this body should become -/// `base64::engine::general_purpose::STANDARD.decode(input)` with +/// produces it). Hand-rolled; swapping in +/// `base64::engine::general_purpose::STANDARD.decode(input)` must keep /// `DecodeError::InvalidByte(_, b)` mapped to the /// `Invalid base64 character: ` message below (pinned by a unit test). pub(crate) fn base64_decode(input: &str) -> Result, String> { @@ -3989,8 +3984,8 @@ mod tests { use super::*; /// The pnpm-PnP hosted lock probe must be boundary-anchored: plain - /// substring matching collided on version prefixes, name suffixes, and - /// unscoped-inside-scoped names (follow-up review finding). + /// substring matching collides on version prefixes, name suffixes, and + /// unscoped-inside-scoped names. #[test] fn pnpm_lock_resolves_is_boundary_anchored() { // v9/v6/v5 key spellings all resolve. @@ -4197,9 +4192,9 @@ mod tests { #[test] fn select_paid_user_picks_highest_severity_not_most_recent() { - // The reported bug. An authorized user's package has a fresh `low` - // patch and an older `critical` one; the old selector took the - // newest and silently left the critical unfixed. + // An authorized user's package has a fresh `low` patch and an older + // `critical` one: taking the newest would leave the critical + // unfixed. let patches = vec![ mk_patch_sev("new_low", "pkg:npm/foo@1.0", "paid", "2026-06-01", "low"), mk_patch_sev( @@ -4309,8 +4304,8 @@ mod tests { #[test] fn select_recency_is_chronological_not_lexicographic() { - // `publishedAt` is RFC 2822 on the wire, so the old raw-string - // compare ordered by weekday name. With equal severities the newer + // `publishedAt` is RFC 2822 on the wire, so a raw-string compare + // would order by weekday name. With equal severities the newer // patch must win regardless of which weekday it fell on. let older = "Wed, 01 Jan 2025 00:00:00 GMT"; let newer = "Fri, 01 Aug 2026 00:00:00 GMT"; @@ -4755,10 +4750,8 @@ mod tests { // --- run_outcome ----------------------------------------------------- // The `status` field and the process exit code are derived from the - // same predicate. Regression guard: a failed *apply* step (no download - // failures) must still report `partial_failure` AND exit 1 — the old - // code keyed `status` only on download failures, so it printed - // `success` next to a non-zero exit code. + // same predicate: a failed *apply* step (no download failures) must + // still report `partial_failure` AND exit 1. #[test] fn run_outcome_clean_is_success_exit_zero() { @@ -4905,12 +4898,11 @@ mod tests { } // --- files_for_manifest / files_with_both_hashes --------------------- - // Regression guards for the download/scan/vendor record builder: a - // net-new file (afterHash, NO beforeHash) that the patch ADDS must be - // retained in the manifest record, not silently dropped. Real prod - // repro: the whole-crate cargo export for `pkg:cargo/traitobject@0.1.1` - // publishes ALL files with only an afterHash — the old both-hashes rule - // recorded `files:{}` and reported `applied:1` while writing nothing. + // The download/scan/vendor record builder: a net-new file (afterHash, NO + // beforeHash) that the patch ADDS must be retained in the manifest + // record, not silently dropped. E.g. the whole-crate cargo export for + // `pkg:cargo/traitobject@0.1.1` publishes ALL files with only an + // afterHash. fn file_resp(before: Option<&str>, after: Option<&str>) -> PatchFileResponse { PatchFileResponse { @@ -4960,8 +4952,8 @@ mod tests { assert_eq!(added.before_hash, ""); assert_eq!(added.after_hash, "a".repeat(64)); - // The old both-hashes rule (still used for installed-variant - // matching) DROPS the added file — this is the behavior we fixed. + // The both-hashes rule (used only for installed-variant matching) + // drops the added file. let strict = files_with_both_hashes(&patch); assert_eq!(strict.len(), 1); assert!(!strict.contains_key("lib/rubygems_plugin.rb")); @@ -4969,9 +4961,8 @@ mod tests { #[test] fn files_for_manifest_keeps_all_new_file_whole_crate_export() { - // The P0 cargo case: EVERY file is a whole-crate export with only - // an afterHash. The old rule produced `files:{}`; the fix retains - // all 9 so the record is non-empty and can actually be applied. + // EVERY file is a whole-crate export with only an afterHash: all 9 + // are retained so the record is non-empty and can be applied. let mut files = HashMap::new(); for i in 0..9 { files.insert( @@ -4985,7 +4976,7 @@ mod tests { assert_eq!(kept.len(), 9, "all whole-crate-export files must be kept"); assert!(kept.values().all(|f| f.before_hash.is_empty())); - // Guardrail precondition: with the old rule this map was empty. + // Guardrail precondition: the both-hashes rule yields an empty map. assert!(files_with_both_hashes(&patch).is_empty()); } @@ -6050,7 +6041,7 @@ mod tests { ); } - // --- coverage mop-up (2026-09 final wave) ------------------------------- + // --- misc edge cases ----------------------------------------------------- /// `merge_metadata` is a best-effort splice: a non-object record (or a /// non-object metadata value) must be left untouched, never panic — @@ -6549,7 +6540,7 @@ mod tests { // The detached download phase must refuse the same Bun projects the // manifest-tracked one does, BEFORE any view fetch (request-log oracle), // and with the vendor code (never the downstream `package_not_installed` - // the alias-shaped lockb project used to degrade to). + // the alias-shaped lockb project would otherwise degrade to). /// A real bun 1.3.14 lockfileVersion-1 workspace lock (matrix capture /// grammar): 1-tuple `workspace:` entry, blank line between entries, @@ -6868,9 +6859,8 @@ mod tests { ); } - /// The nested apply inherits the caller's flags verbatim (`--verbose` - /// and `--strict` were dropped when its args were rebuilt from Default), - /// with `json`/`dry_run` forced off — one JSON document per run, and + /// The nested apply inherits the caller's flags verbatim (`--verbose`, + /// `--strict`, …), with `json`/`dry_run` forced off — one JSON document per run, and /// agent-mode `get` ignores `--dry-run` — `silent` following the caller's /// quiet gate, and the manifest path absolutized so apply does not /// re-resolve it against its own `--cwd`. diff --git a/crates/socket-patch-cli/src/lib.rs b/crates/socket-patch-cli/src/lib.rs index c6437920..cd18d33d 100644 --- a/crates/socket-patch-cli/src/lib.rs +++ b/crates/socket-patch-cli/src/lib.rs @@ -75,7 +75,7 @@ pub enum Commands { /// references plus any agent-mode manifest entries List(commands::list::ListArgs), - /// Agent mode: get a patch from the Socket API and apply it + /// Patch one package, CVE, GHSA or patch UUID (hosted mode by default) #[command(visible_alias = "download")] Get(commands::get::GetArgs), @@ -106,10 +106,10 @@ pub enum Commands { // in `parse_argv_with_shortcuts`). Hidden: the public contract // surface is `socket-patch --update`, and this name carries no // stability guarantee (documented as internal in CLI_CONTRACT.md). - // Plain `//` comments plus an explicit `about`/`override_usage`: a doc - // comment here is what `socket-patch --update --help` printed, and the - // derived usage line named the hidden subcommand, and so did the - // `--update --version` line until `display_name` pinned it. + // Plain `//` comments plus an explicit `about`/`override_usage`/ + // `display_name`: a doc comment here would become + // `socket-patch --update --help` text, and the derived usage and + // `--update --version` lines would name the hidden subcommand. #[command( hide = true, name = "self-update", diff --git a/crates/socket-patch-cli/tests/cli_get_silent_errors.rs b/crates/socket-patch-cli/tests/cli_get_silent_errors.rs index e829e461..61dd3818 100644 --- a/crates/socket-patch-cli/tests/cli_get_silent_errors.rs +++ b/crates/socket-patch-cli/tests/cli_get_silent_errors.rs @@ -1,13 +1,9 @@ //! `get --silent` must still surface errors. //! //! CLI_CONTRACT.md defines `--silent` as "Errors only" — informational -//! chatter is suppressed, errors are not. Regression guard: the download -//! loop in `download_and_apply_patches` gated its per-patch failure lines -//! (`[fail] …`) on `!silent` alongside the informational prints, so -//! `get --silent` against a failing patch fetch exited 1 with ZERO -//! output anywhere — no stdout (correct) and no stderr (the bug). The -//! by-uuid path (`save_and_apply_patch`) already kept its blob errors -//! visible under `--silent`; the search path must match. +//! chatter is suppressed, errors are not. The per-patch failure lines +//! (`[fail] …`) in `download_and_apply_patches_with` must reach stderr under +//! `--silent`, matching the by-uuid path (`save_and_apply_patch`). //! //! Hermetic: the search endpoint answers with one free patch and the //! patch view endpoint answers 500, all on a local wiremock; ambient API @@ -38,7 +34,7 @@ fn dead_env<'a>() -> Vec<(&'a str, &'a str)> { } /// Search succeeds (one free patch) but the patch view fails: the run -/// reaches `download_and_apply_patches`' failure branch and must exit 1. +/// reaches `download_and_apply_patches_with`' failure branch and must exit 1. async fn mount_search_ok_view_500(mock: &MockServer) { Mock::given(method("GET")) .and(path(format!( @@ -69,6 +65,8 @@ fn get_args<'a>(uri: &'a str, extra: &[&'a str]) -> Vec<&'a str> { let mut args = vec![ "get", PURL, + "--mode", + "agent", "--yes", "--api-url", uri, @@ -105,8 +103,8 @@ async fn get_silent_download_failure_still_prints_the_error() { stderr.contains("[fail]") && stderr.contains(PURL), "--silent must still print the download failure to stderr; got {stderr:?}" ); - // Informational chatter stays suppressed: the fix must not turn - // --silent failures into fully loud runs. + // Informational chatter stays suppressed: --silent failures must not + // become fully loud runs. assert!( !stderr.contains("Downloading"), "--silent must keep suppressing informational lines; got {stderr:?}" diff --git a/crates/socket-patch-cli/tests/covgap_commands_get.rs b/crates/socket-patch-cli/tests/covgap_commands_get.rs index be0c85d8..ba006e94 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_get.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_get.rs @@ -1,8 +1,8 @@ -//! Coverage-gap tests for `commands/get.rs` (coverage audit 2026-09). +//! Coverage-gap tests for `commands/get.rs`. //! -//! Targets the audited never-executed branches: `run()`'s flag/package-path +//! Targets otherwise-untested branches: `run()`'s flag/package-path //! edges, the `save_patch_record` failure ladder on the uuid path, the -//! `download_and_apply_patches` engine failure branches, the release-variant +//! `download_and_apply_patches_with` engine failure branches, the release-variant //! narrowing fallbacks (fabricated PyPI venv — no real python needed), the //! search-path `--mode vendored` flow, the vendor-step error arms, and every //! human-mode (non `--json`) output path the existing suites left to @@ -105,7 +105,7 @@ fn default_args(identifier: &str, cwd: &Path) -> GetArgs { save_only: true, one_off: false, all_releases: false, - mode: None, + mode: Some(socket_patch_cli::commands::scan::ScanMode::Agent), } } @@ -132,15 +132,10 @@ async fn mount_view_files(server: &MockServer, uuid: &str, purl: &str, files: se .await; } -/// `view/{uuid}` served exactly ONCE: the get's own fetch succeeds, and the -/// vendor step's in-memory staging — which fetches the view again — then -/// 404s, tripping the `no_local_source` staging refusal. /// A view whose files carry hashes but NO `blobContent`: the download /// phase records it fine (hashes only), but the vendor step has nothing to /// stage from — not in the download phase's blob seed, not on disk, and not -/// from the view it re-fetches — so it dies `no_local_source`. (Serving a -/// good view exactly once no longer produces that: the step stages from -/// the seed and never fetches the view a second time.) +/// from the view it re-fetches — so it dies `no_local_source`. async fn mount_contentless_view(server: &MockServer, uuid: &str, purl: &str) { let mut files = good_files(); files["package/index.js"] @@ -444,6 +439,10 @@ async fn mount_ghsa_fanout(server: &MockServer) { fn run_get_bin(cwd: &Path, api_url: &str, extra: &[&str]) -> (i32, String, String) { let mut args = vec!["get"]; args.extend_from_slice(extra); + // v5 `get` defaults to hosted; these fixtures drive agent mode. + if !extra.contains(&"--mode") { + args.extend_from_slice(&["--mode", "agent"]); + } args.extend_from_slice(&[ "--api-url", api_url, @@ -473,9 +472,7 @@ fn parse_single_json_doc(stdout: &str) -> serde_json::Value { // =========================================================================== /// Two identifier type flags together must be rejected up front: exit 1, -/// nothing fetched, nothing written. This branch (line-level: the -/// `type_flags > 1` guard) had never executed — every caller passes at most -/// one flag. +/// nothing fetched, nothing written (the `type_flags > 1` guard). #[tokio::test] #[serial] async fn get_conflicting_type_flags_rejected_before_any_network() { @@ -1800,9 +1797,9 @@ async fn human_uuid_not_found_prints_message() { assert_no_manifest(tmp.path()); } -/// Human CVE search with no results: both the search label and the -/// per-type not-found message (the `IdentifierType` Display impl's only -/// consumer) must print. +/// Human CVE search with no results: the per-type not-found message prints +/// on stdout, while the transient `Searching patches for …` status line +/// never reaches a pipe. #[tokio::test] async fn human_cve_search_empty_prints_search_label_and_not_found() { let server = MockServer::start().await; @@ -3001,6 +2998,8 @@ async fn agent_dry_run_previews_only_the_installed_release_variant() { &[ "get", GHSA, + "--mode", + "agent", "--dry-run", "--json", "--api-url", diff --git a/crates/socket-patch-cli/tests/get_nested_apply_api_flags_e2e.rs b/crates/socket-patch-cli/tests/get_nested_apply_api_flags_e2e.rs index 14950428..e55906b7 100644 --- a/crates/socket-patch-cli/tests/get_nested_apply_api_flags_e2e.rs +++ b/crates/socket-patch-cli/tests/get_nested_apply_api_flags_e2e.rs @@ -13,7 +13,7 @@ //! file (the manifest records the hashes; the bytes are fetched on demand): //! `get` writes no blob, and the nested apply has to download it. Both `get` //! call sites are covered — the direct-UUID path (`save_and_apply_patch`) and -//! the search path (`download_and_apply_patches`). +//! the search path (`download_and_apply_patches_with`). //! //! Hermetic by construction: the *env* API/proxy URLs point at a dead local //! port, so a run that ignores the flags fails on a refused connection rather @@ -162,6 +162,8 @@ async fn get_by_uuid_nested_apply_uses_api_flags_not_env() { &[ "get", UUID, + "--mode", + "agent", "--yes", "--json", // `file` mode goes straight for the per-file blob endpoint; the @@ -216,6 +218,8 @@ async fn get_by_purl_nested_apply_uses_api_flags_not_env() { &[ "get", PURL, + "--mode", + "agent", "--yes", "--json", "--download-mode", @@ -296,6 +300,8 @@ async fn get_by_uuid_nested_apply_uses_proxy_url_flag_when_tokenless() { &[ "get", UUID, + "--mode", + "agent", "--yes", "--json", "--download-mode", diff --git a/crates/socket-patch-cli/tests/in_process_get_manifest_path.rs b/crates/socket-patch-cli/tests/in_process_get_manifest_path.rs index 04d30f83..152819af 100644 --- a/crates/socket-patch-cli/tests/in_process_get_manifest_path.rs +++ b/crates/socket-patch-cli/tests/in_process_get_manifest_path.rs @@ -105,7 +105,7 @@ fn get_args(identifier: &str, cwd: &Path, api_url: String) -> GetArgs { save_only: true, one_off: false, all_releases: false, - mode: None, + mode: Some(socket_patch_cli::commands::scan::ScanMode::Agent), } } @@ -178,7 +178,7 @@ async fn get_by_uuid_honors_custom_manifest_path() { } // --------------------------------------------------------------------------- -// 2. --manifest-path honored on the search flow (download_and_apply_patches) +// 2. --manifest-path honored on the search flow (download_and_apply_patches_with) // --------------------------------------------------------------------------- #[tokio::test] diff --git a/crates/socket-patch-cli/tests/in_process_get_modes.rs b/crates/socket-patch-cli/tests/in_process_get_modes.rs index 1b30aa30..f2317fb0 100644 --- a/crates/socket-patch-cli/tests/in_process_get_modes.rs +++ b/crates/socket-patch-cli/tests/in_process_get_modes.rs @@ -70,7 +70,7 @@ fn get_args(identifier: &str, cwd: &Path, api_url: String) -> GetArgs { save_only: false, one_off: false, all_releases: false, - mode: None, + mode: Some(socket_patch_cli::commands::scan::ScanMode::Agent), } } From 462a35b049225a0100772c5a948b6401f4622153 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Sun, 27 Sep 2026 16:43:12 -0400 Subject: [PATCH 09/32] Rewrite the README, CLI contract and docs for v5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README now leads with the scan → vex → vendor workflow: hosted mode first, vendored for offline installs, agent mode as the older install-hook flow, and the command reference in that order. The contract and README now match the code on these points: - a bare scan and get run hosted mode - scan never prompts - --package - PATH semantics in each mode - the merged-first patch ranking - the exit-2 cases - the env var table The Bundler plugin README now says setup writes a path: source and that failures warn by default. The docs/testing notes drop references to files and flags that no longer exist. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 19 +- README.md | 1049 ++++++++++++----------- crates/socket-patch-cli/CLI_CONTRACT.md | 211 ++--- docs/design/configuration.md | 10 +- docs/design/golang-hosted-no-go.md | 4 +- docs/ecosystems.md | 3 +- docs/testing/bun-compatibility.md | 2 +- docs/testing/hosted-production-e2e.md | 38 +- docs/testing/pipenv-compatibility.md | 7 +- docs/testing/uv-compatibility.md | 2 +- docs/testing/vendored-production-e2e.md | 4 +- gem/socket-patch-bundler/README.md | 12 +- tests/docker/README.md | 89 +- tests/setup_matrix/README.md | 7 +- 14 files changed, 746 insertions(+), 711 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e853cce..33a44ba3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,8 +65,8 @@ into the new version's section — see docs/releasing.md. refusals were the vendor step's only failures (and when every selected package is refused this way, the human arm prints `Nothing was vendored: N patches failed (see above).`). - (The interactive human `scan --vendor` arm still fetches the views its - pre-prompt baseline check verifies.) Purls the hosted redirect ledger + (The human `scan --mode vendored` arm still fetches the views its + baseline pre-check verifies.) Purls the hosted redirect ledger claims keep the vendor loop's refusal. Because no view is fetched, the lock-text refusal now takes precedence over every outcome that came from the view: a package that would also have hit a paid-access 403, a @@ -263,6 +263,11 @@ into the new version's section — see docs/releasing.md. malformed redirect ledger on a human hosted run that stops before the engine is reported as the read-only `Warning: the redirect ledger … is malformed` advisory instead of nowhere. +- **`get` defaults to hosted mode too.** `socket-patch get ` (and the + bare-UUID shortcut `socket-patch `) now redirects the package's + lockfile entry to its Socket-hosted patched copy, like `scan`. Agent mode + (manifest + in-place apply) is `--mode agent`, and stays the default with + `--save-only` or `--global`/`--global-prefix`. - **scan picks the newest merged patch.** When a package has several patches, `scan` (and `get`, and the `[UPDATE]` detection) now takes the newest merged patch (one that fixes several advisories in one blob — the @@ -721,8 +726,8 @@ into the new version's section — see docs/releasing.md. already-redirected or already-vendored lock-only checkout re-confirms it; a lock that resolves only from private indexes is never looked up on pypi.org. -- **Pipenv's out-of-tree virtualenv is discovered.** Agent mode (bare `scan`, - `rollback`, `vex`) now finds `$WORKON_HOME/-[-]` (the +- **Pipenv's out-of-tree virtualenv is discovered.** Discovery (`scan` in + every mode, `rollback`, `vex`) now finds `$WORKON_HOME/-[-]` (the `.venv` file pointer, `PIPENV_CUSTOM_VENV_NAME` and `PIPENV_PIPFILE` included) exactly as Pipenv 7 through 2026 place it, instead of falling through to the global interpreter's site-packages. @@ -1185,8 +1190,8 @@ into the new version's section — see docs/releasing.md. `--json`/`--silent`. `fetch`, `vendor`, `setup`, lock waits and `--update` checks now show progress instead of going quiet. - **Prompts:** Ctrl-D at a `[Y/n]` prompt now declines instead of - accepting. Keys pressed while a scan is running no longer answer the - prompt that follows. The cursor is restored when a selection menu is + accepting. Keys pressed while a command is working no longer answer + the prompt that follows (`get`, `rollback`). The cursor is restored when a selection menu is interrupted. - **Color:** `NO_COLOR`, `CLICOLOR`, `CLICOLOR_FORCE` and `TERM=dumb` are honored. Colored table rows now align. @@ -1302,7 +1307,7 @@ into the new version's section — see docs/releasing.md. connect timeout (a blackholed endpoint no longer stalls every command for the full request budget), and `--update` maps only a contention errno to `update_in_progress` — other lock failures surface their real cause. -- **A normal `scan` never creates `.socket/`.** Report-only, `--dry-run`, +- **A scan that writes nothing never creates `.socket/`.** Report-only, `--dry-run`, zero-discovery and no-op runs (hosted or otherwise) no longer scaffold the directory or a lock file; a GC pass checks for a manifest before it locks. - **`apply --silent` on an all-unmatched manifest prints its error line** — diff --git a/README.md b/README.md index 4b77c032..af7fc5af 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,14 @@ Fix known vulnerabilities in the dependencies you already have — without waiti upstream release, and without a risky version bump. Socket's security team backports minimal fixes to the *exact versions* of packages you -have installed. The `socket-patch` CLI finds which of your dependencies have a patch -available and applies it, verifying every changed file by hash. It works across npm, -PyPI, Cargo, Go, RubyGems, Maven, Composer, NuGet, and Deno, and it can persist the patches -whichever way fits your workflow: re-applied by the CLI, committed to your repo, or -pinned in your lockfile. When you're done, it can emit an [OpenVEX -attestation](#openvex-attestations) so your vulnerability scanner stops flagging the -CVEs you've already fixed. +use. `socket-patch scan` finds which of your dependencies have a patch and rewrites your +lockfile so that only those dependencies resolve to Socket-hosted, integrity-pinned +patched packages. Your package manager then installs the fix like any other dependency: +no install hook, no CI changes. It works across npm, PyPI, Cargo, Go, RubyGems, Maven, +Composer, NuGet, and Deno. `socket-patch vex` then emits an [OpenVEX +attestation](#openvex-attestations) so your vulnerability scanner stops flagging the CVEs +you've fixed, and `socket-patch scan --mode vendored` commits the patched packages into +your repo when installs must work offline. **Contents:** [Installation](#installation) · [Five-minute tutorial](#five-minute-tutorial) · [How it works](#how-socket-patch-works) · [Common tasks](#common-tasks) @@ -124,25 +125,24 @@ talks to Socket's public patch proxy, which serves the free tier of patches anon `socket-patch` picks it up automatically — see [Configuration sources](#configuration-sources).) -**1. Scan your project.** From your project root, ask Socket which of your installed -dependencies have patches available: +**1. Scan.** From your project root: ```bash cd your-project socket-patch scan ``` -`scan` crawls the installed packages it finds (`node_modules/`, virtualenvs, the cargo -registry cache, and so on), queries the patch database, prints each available patch with -its package, severity, and CVE/GHSA identifiers, and asks whether to apply. Say yes and -the vulnerable files are rewritten in place — each file is hash-verified before and after -the edit. - -> If it prints `No patches available for installed packages.`, none of your installed -> dependency versions currently has a Socket patch — the good outcome, with nothing to -> apply. -> To walk the rest of the loop anyway, make a scratch project pinned to a version -> that has a free patch — at the time of writing, `flatted@3.3.1`: +`scan` reads your lockfiles and installed packages, asks Socket which dependency +versions have a patch, prints each one with its severity and CVE/GHSA identifiers, and +patches them in **hosted mode**: it rewrites the lockfile so only the patched +dependencies resolve to Socket-hosted, integrity-pinned packages on `patch.socket.dev`. +It never prompts. The patch records go in `.socket/vendor/redirect-state.json`, and the +run ends by listing the files it changed. (Add `--dry-run` to preview without writing.) + +> If it prints `No patches available for installed packages.`, none of your dependency +> versions currently has a Socket patch — the good outcome, with nothing to do. To walk +> the rest of the loop anyway, make a scratch project pinned to a version that has a free +> patch — at the time of writing, `flatted@3.3.1`: > > ```bash > mkdir demo && cd demo && git init -q && npm init -y && npm install flatted@3.3.1 && socket-patch scan @@ -151,72 +151,118 @@ the edit. > (The patch catalog changes over time; if that finds nothing, pick another patched > version.) -**2. See what you have.** The applied patches are recorded in `.socket/manifest.json`: +**2. Commit.** The lockfile edit *is* the patch, so commit it with the redirect ledger +(`rollback` and `vex` read it): ```bash -socket-patch list +git add package-lock.json .socket/vendor/redirect-state.json .npmrc # npm example +git commit -m "apply Socket security patches" ``` +The exact files depend on your package manager — `scan` names them. For npm it also +writes `allow-remote=all` to `.npmrc`, which npm 12 needs to install from +`patch.socket.dev` (see [npm: hosted mode and npm 12](#npm-hosted-mode-and-npm-12)); +for pnpm 9+ locks it sets `trustLockfile: true` in `pnpm-workspace.yaml` +([pnpm](#pnpm)). + +**3. Reinstall.** A clean install fetches the patched packages and checks them against +the lockfile's integrity pins: + +```bash +npm ci # or pnpm install, yarn install, pip install -r ..., uv sync, bundle install, ... ``` -Found 1 patch: -Package: pkg:npm/flatted@3.3.1 - UUID: 5cac955f-eab1-4d29-8f4f-c408a6cc9647 - ... - Vulnerabilities (1): - - GHSA-25h7-pfq9-p65f (CVE-2026-32141) - Severity: HIGH +Every later install — yours, your teammates', CI's — does the same. There is no hook to +wire and nothing to add to CI. + +**4. Tell your scanner.** Emit an OpenVEX document that marks each patched CVE +`not_affected`, and hand it to Grype, Trivy or any other VEX-aware scanner: + +```bash +socket-patch vex --output socket.vex.json +grype . --vex socket.vex.json ``` -**3. Make it stick.** Patches applied in place don't survive a reinstall — the next -`npm install` (or `pip install`, `bundle install`, …) restores the vulnerable upstream -bytes. Commit the `.socket/` directory and wire an install hook so patches re-apply -automatically: +**5. Go offline, if you need to.** Hosted installs must reach `patch.socket.dev`. For +airgapped builds, switch to vendored mode: it copies the patched packages into +`.socket/vendor/`, points the lockfile at them, and reverts the hosted edits: ```bash -socket-patch setup # e.g. adds a postinstall script for npm projects -git add .socket package.json # npm example — setup prints which files it changed -git commit -m "apply Socket security patches" +socket-patch scan --mode vendored +git add .socket/vendor package-lock.json .npmrc && git commit -m "vendor Socket patches" ``` -From now on, every install — yours, your teammates', CI's — re-applies the patches. You -can also re-apply manually at any time with `socket-patch apply` (it's idempotent). +(Vendored npm installs don't need the `.npmrc` line, so the switch removes it again.) -**4. Undo, if you want.** Remove a patch completely (restores the original files and -deletes the manifest entry): +**6. See what you have.** `list` shows each patch and the mode that holds it +(`Mode: vendored` after step 5): ```bash -socket-patch remove "pkg:npm/flatted@3.3.1" +socket-patch list +``` + +``` +Found 1 patch: + +Package: pkg:npm/flatted@3.3.1 + UUID: 5cac955f-eab1-4d29-8f4f-c408a6cc9647 + Mode: hosted (recorded in .socket/vendor/redirect-state.json) + ... + Vulnerabilities (1): + - GHSA-25h7-pfq9-p65f (CVE-2026-32141) + Severity: HIGH ``` -That's the whole loop: **scan → apply when prompted → setup → commit**. This tutorial -used the default *agent* mode, where the CLI re-applies patches after each install. -There are two other ways to persist patches — committing the patched packages themselves -(*vendored*) or pinning them in your lockfile (*hosted*) — and choosing between the -three is the next section. +To undo everything, run `socket-patch rollback`: it restores the original lockfile +entries and drops the records. + +That's the whole loop: **scan → commit → reinstall → vex**, with `scan --mode vendored` +when installs must be offline. The older *agent* mode, which patches installed files in +place and re-applies them from an install hook, is still supported; the next section +compares the three. ## How Socket Patch works **A patch** is a minimal fix — usually the upstream security fix, backported — for one exact published version of a package. Socket distributes it as per-file edits: for each touched file, the hash of the expected original (`beforeHash`), the hash of the patched -result (`afterHash`), and the replacement content. By default, a file whose current -content matches neither the expected original nor the patched result is overwritten with -the full verified patched content plus a stderr warning (`content_mismatch_overwritten`); -pass `--strict` (a [global option](#global-options)) to fail closed on mismatch instead, -or `apply --force` to skip pre-application hash verification entirely (see -[`apply`](#apply)). Either way the CLI verifies the result after writing. Patches are -looked up by package URL ([PURL](https://github.com/package-url/purl-spec)) — e.g. -`pkg:npm/lodash@4.17.20` — so everything is keyed to exact versions. - -**Local state lives in `.socket/`** at your project root, and is designed to be -committed: +result (`afterHash`), and the replacement content. Patches are looked up by package URL +([PURL](https://github.com/package-url/purl-spec)) — e.g. `pkg:npm/lodash@4.17.20` — so +everything is keyed to exact versions. In hosted and vendored mode your package manager +installs a patched copy of the package, pinned by the lockfile's integrity check; in +agent mode the CLI edits +the installed files itself and verifies every hash before and after (a file matching +neither hash is overwritten with the verified patched content plus a +`content_mismatch_overwritten` warning, unless `--strict` is set). + +### Which patch is picked + +A package version can have several patches (one per advisory, or a later *merged* patch +that folds several advisories into one). `scan` and `get` apply exactly one, chosen +the same way everywhere, from the patches your account can download: + +1. the newest **merged** patch (one covering two or more advisories) wins, regardless of + severity — it is the cumulative fix; +2. otherwise the patch with the worst severity it fixes (critical > high > medium > low), + then the newest; +3. paid tier, then UUID, only break exact ties. + +"Newest" is when the patch was published, not the package version. When a better patch +appears for a package you already patched, the JSON `updates[]` array lists it and the +next `scan` in the same mode takes it. + +### State in `.socket/` + +Local state lives in `.socket/` at your project root, and is designed to be committed: | Path | Contents | |------|----------| -| `.socket/manifest.json` | Agent mode: the record of downloaded patches — PURLs, file hashes, vulnerability metadata ([format](#manifest-format)) | -| `.socket/blobs/` | Agent mode: patched file contents, named by git-sha256 hash | -| `.socket/vendor/` | Vendored package artifacts and the vendor/redirect ledgers — the **only** state vendored and hosted modes write (the vendor ledger embeds the patch records; neither mode touches `manifest.json`) | +| `.socket/vendor/redirect-state.json` | Hosted mode: the patch records plus the original lockfile / registry-config fragments each redirect replaced (what [`rollback`](#rollback) replays) | +| `.socket/vendor/state.json` + `.socket/vendor//…` | Vendored mode: the ledger (with embedded patch records) and the patched package artifacts | +| `.socket/manifest.json` | Agent mode only: the record of downloaded patches — PURLs, file hashes, vulnerability metadata ([format](#manifest-format)) | +| `.socket/blobs/` | Agent mode only: patched file contents, named by git-sha256 hash | + +Hosted and vendored mode never write `manifest.json`. > While a command runs it holds a transient advisory lock, `.socket/apply.lock`, and > removes it when it finishes — the file never outlives the command, so there is nothing @@ -228,40 +274,39 @@ committed: ### Three patch modes -The same patched bytes can reach your build three different ways. The modes differ in -*where the patch lives* and *what must happen at install time*; pick one per project -(`scan --mode ` drives exactly one mode per run). +The same patched bytes can reach your build three ways. The modes differ in *where the +patch lives* and *what must happen at install time*. `scan --mode ` picks one per +run; a bare `scan` is hosted. | Mode | Where the patch lives | Install-time requirement | Trade-off | |------|----------------------|--------------------------|-----------| -| **agent** — `scan --mode agent` (or [`apply`](#apply)) | `.socket/` manifest + blobs, committed; the CLI re-applies after each install | The `socket-patch` CLI must run (install hook via [`setup`](#setup), or an `apply` step in CI) | Small repo footprint (per-file blobs, not whole packages); no lockfile edits; the only mode that needs CI / install-hook changes | -| **vendored** — `scan --mode vendored` (or [`vendor`](#vendor)) | Patched packages committed under `.socket/vendor/` (with a ledger that embeds the patch records — `scan --mode vendored` writes no manifest; the standalone `vendor` command is manifest-driven and keeps only a fallback copy in the ledger); the lockfile is rewired to consume them | **None** — the package manager installs the committed bytes | Fully airgapped and hermetic, at the cost of repo size | -| **hosted** — `scan --mode hosted` | No patched bytes in your repo: the lockfile is rewritten so **only** the patched dependencies resolve to Socket-hosted, integrity-pinned packages on `patch.socket.dev`; the edits + patch records are ledgered in `.socket/vendor/redirect-state.json` (commit it — [`rollback`](#rollback) replays its recorded pre-redirect originals to unwind the redirect, see [Undo things](#undo-things), and [`vex`](#vex) uses its records offline; `vex` also works from the rewritten lockfile alone) | Installs must be able to reach `patch.socket.dev` (no CLI, no install hook) | Smallest possible diff (lockfile + ledger); not for airgapped installs | - -Every mode pins the patched bytes: in agent mode the CLI verifies every file on each -apply; vendored and hosted modes lean on your package manager's own lockfile integrity -checks (sha512 / sha256 / contentHash / CHECKSUMS) where the ecosystem enforces them — -hosted Maven, which has no lockfile, gets a fail-closed version-suffixing scheme instead. -A few combinations have weaker install-time pins (vendored Maven, NuGet without a -lockfile, Go's directory replaces, pipenv's Pipfile.lock) — there the committed bytes -are the protection; see the [per-ecosystem caveats](docs/ecosystems.md). - -**Choosing:** *agent* is the original method and remains fully supported, but it is the -only mode that requires CI / install-hook modification — **new projects should prefer -hosted or vendored**. Pick *vendored* if your builds are airgapped or you don't want an -infrastructure dependency; pick *hosted* if you want the smallest diff and your installs -can reach `patch.socket.dev`. (Hosted is the planned default for GitHub-app patch PRs — -it keeps the PR diff small.) - -Mode support varies by ecosystem — e.g. Go can't do hosted, Rush monorepos can't do -vendored. See the full **[mode × ecosystem matrix](docs/ecosystems.md#mode--ecosystem-matrix)** -for details and per-ecosystem caveats. - -### npm compatibility (hosted mode and npm 12) +| **hosted** (default) — `scan` | Nowhere in your repo: the lockfile is rewritten so **only** the patched dependencies resolve to Socket-hosted, integrity-pinned packages on `patch.socket.dev`; the edits and patch records are ledgered in `.socket/vendor/redirect-state.json` | Installs must be able to reach `patch.socket.dev` (no CLI, no install hook) | Smallest possible diff (lockfile + ledger); not for airgapped installs | +| **vendored** — `scan --mode vendored` (or [`vendor`](#vendor)) | Patched packages committed under `.socket/vendor/`, with the lockfile rewired to consume them | **None** — the package manager installs the committed bytes | Fully airgapped and hermetic, at the cost of repo size | +| **agent** (older) — `scan --mode agent`, [`get`](#get), [`apply`](#apply) | `.socket/manifest.json` + blobs, committed; the CLI patches installed files in place | The `socket-patch` CLI must run after every install (an install hook via [`setup`](#setup), or an `apply` step in CI) | No lockfile edits and a small repo footprint, but the only mode that needs CI / install-hook changes | + +Every mode pins the patched bytes: vendored and hosted modes lean on your package +manager's own lockfile integrity checks (sha512 / sha256 / contentHash / CHECKSUMS) where +the ecosystem enforces them — hosted Maven, which has no lockfile, gets a fail-closed +version-suffixing scheme instead — and agent mode verifies every file on each apply. A +few combinations have weaker install-time pins (vendored Maven, NuGet without a lockfile, +Go's directory replaces, pipenv's Pipfile.lock) — there the committed bytes are the +protection; see the [per-ecosystem caveats](docs/ecosystems.md). + +**Choosing:** use *hosted* unless your installs can't reach `patch.socket.dev`; then +use *vendored*. *Agent* mode remains fully supported for projects already built around +it, and for Deno, which has no hosted or vendored mode. + +Mode support varies by ecosystem — e.g. Rush monorepos can't do vendored, and Go hosted +mode covers the free tier only. See the full +**[mode × ecosystem matrix](docs/ecosystems.md#mode--ecosystem-matrix)** for details. + +### Package-manager notes + +#### npm: hosted mode and npm 12 npm 12 defaults to `allow-remote=none` and refuses (`EALLOWREMOTE`) any lockfile entry whose tarball is not served by your configured registry — which is what a -hosted redirect writes. So when `scan --mode hosted` (or `get --mode hosted`) leaves a +hosted redirect writes. So when a hosted `scan` (or `get --mode hosted`) leaves a `package-lock.json` / `npm-shrinkwrap.json` pointing at `patch.socket.dev`, it also writes `allow-remote=all` to the project `.npmrc` (creating it, or appending one line and keeping everything else byte-for-byte) and warns `redirect_npm_allow_remote`. @@ -279,7 +324,17 @@ mode remove exactly the line or file the run added. Vendored mode needs none of npm treats its `file:` tarballs under `allow-file`, which defaults to `all`. See [npm compatibility](docs/testing/npm-compatibility.md) for the tested majors. -### Bun compatibility +#### pnpm + +For a lockfileVersion 9 `pnpm-lock.yaml`, hosted mode also sets `trustLockfile: true` in +`pnpm-workspace.yaml` (pnpm 11+ rejects the redirected lock without it; commit the file +with the lock) unless the project disables it or you pass `--no-trust-lockfile-config`. +It skips pnpm's registry re-verification for the whole lock, while tarball integrity +stays enforced. A warm store can keep serving the upstream bytes, so reinstall from a +clean tree and an empty store, then check with `socket-patch vex`. See +[pnpm compatibility](docs/testing/pnpm-compatibility.md). + +#### Bun Both text `bun.lock` and binary `bun.lockb` support hosted and vendored patches, mode switching, repair, and rollback. Binary locks are read and @@ -288,130 +343,87 @@ rewrite them, and does not convert them to text. If both filenames exist, `bun.lock` takes precedence. See [Bun compatibility](docs/testing/bun-compatibility.md) for the tested versions, workspace behavior, and installer integrity limits. -### vlt compatibility +#### vlt [vlt](https://www.vlt.sh) projects (`vlt-lock.json`) work in agent and hosted mode on -every vlt release from 0.0.0-1 to 1.2.0 (both DepID grammars and every -`lockfileVersion`), and in vendored mode on locks with `lockfileVersion` 0 or 1 -(0.0.0-19 and later); older locks are refused with `vendor_lockfile_version_unsupported`. -Agent mode patches each copy in `node_modules/.vlt` without writing through vlt 1.2's -shared store. Hosted mode repoints the patched nodes' integrity and URL, first checks -that each artifact is served the way vlt can verify, and removes stale installed copies -so the next `vlt install` fetches the patched packages. Vendored mode commits a patched -package directory for each direct dependency of the root or a workspace member -(transitive dependencies need hosted mode), including one vlt gave a single peer -context; after vendoring an optional dependency run `vlt ci`, because a plain `vlt -install` keeps its installed upstream copy. vlt is detected ahead of every other -npm-family package manager. vlt ledgers require the socket-patch release that adds vlt support. -See [vlt notes](docs/ecosystems.md#npm-vlt-notes) for the caveats (`vlt update`, optional -dependencies, registry configuration) and [vlt compatibility](docs/testing/vlt-compatibility.md) -for the tested releases. - -### Pipenv compatibility - -Hosted mode rewrites every `Pipfile.lock` category that pins the patched -release (`default`, `develop`, and Pipenv 2022+ named categories) and -preserves the Pipfile, its content hash, markers, extras, and unrelated lock -entries, so `pipenv install --deploy`, `pipenv sync` and `pipenv verify` keep -passing. The reference shape follows the installing Pipenv: releases 7–11 -need `path` references, 2018 and later use `file` references, and lock -formats before `pipfile-spec: 6` (Pipenv 0–6) are refused without changing -the lock. Socket Patch probes `pipenv --version` once per run (only when a -patch targets the lock); `SOCKET_PIPENV_MAJOR=` pins the answer for -machines without pipenv on PATH. Hosted references carry both the `#sha256=` -URL fragment (verified by Pipenv 2023+) and a `hashes` entry (verified by -2018–2022; Pipenv 11 verifies either), so a tampered lock fails to install -on every supported release. - -Vendored mode requires Pipenv 2018 or later. Wheels with extras use `path` -references to avoid Pipenv 2022's local-file URL parsing bug. Pipenv 2023+ -does not enforce hashes on local wheels; commit the wheel and run -`socket-patch vex --product ` (a Pipfile names no project, so pass the -product purl explicitly). - -Fresh checkouts work in every mode: a clone with only `Pipfile` + -`Pipfile.lock` is discovered from the lock (hosted redirects it, vendored -fetches the pristine wheel by one of the lock's recorded digests), and agent -mode finds Pipenv's default out-of-tree virtualenv under `WORKON_HOME` -without `pipenv run`. - -Pipenv never reinstalls a release that is already present: `pipenv install`, -`pipenv install --deploy` and `pipenv sync` all exit 0 and keep the installed -bytes, on every Pipenv major. A hosted or vendored rewrite therefore -protects fresh installs, and Socket Patch warns -(`redirect_pypi_stale_install` / `pypi_pipenv_stale_install`) when a venv -still holds the upstream release, with the verified remedy: -`pipenv run pip uninstall -y && pipenv sync` (or `pipenv --rm && -pipenv sync`). Do not use `pipenv uninstall ` for this — it rewrites the -Pipfile and re-locks the patch away. `pipenv lock` / `pipenv update` -regenerate the entry to its registry reference (a silent unpatch): re-run -Socket Patch afterwards; `rollback` retires the stale record cleanly. - -`scripts/backtest-pipenv.py` drives the real CLI and the last stable release -of every published Pipenv major through hosted, vendored, agent and -out-of-tree agent mode, and `docs/testing/pipenv-compatibility.md` holds the -measured boundaries and results. +every vlt release from 0.0.0-1 to 1.2.0, and in vendored mode on locks with +`lockfileVersion` 0 or 1 (0.0.0-19 and later; older locks are refused with +`vendor_lockfile_version_unsupported`). Hosted mode checks that each artifact is served +the way vlt can verify and removes stale installed copies so the next `vlt install` +fetches the patched packages. Vendored mode covers direct dependencies of the root or a +workspace member (transitive dependencies need hosted mode); after vendoring an optional +dependency run `vlt ci`. See [vlt notes](docs/ecosystems.md#npm-vlt-notes) for the +caveats and [vlt compatibility](docs/testing/vlt-compatibility.md) for the tested +releases. + +#### Pipenv + +Hosted mode rewrites every `Pipfile.lock` category that pins the patched release and +keeps the Pipfile, its content hash, markers and unrelated entries, so `pipenv install +--deploy`, `pipenv sync` and `pipenv verify` keep passing (Pipenv 7 and later; older +lock formats are refused unchanged). Socket Patch probes `pipenv --version` once per run +to pick the reference shape; `SOCKET_PIPENV_MAJOR=` pins it on machines without +pipenv. Vendored mode requires Pipenv 2018 or later; Pipenv 2023+ does not hash-check +local wheels, so commit the wheel and run `socket-patch vex --product ` (a Pipfile +names no project). A clone with only `Pipfile` + `Pipfile.lock` works in every mode. + +Pipenv never reinstalls a release that is already present, so a rewrite protects fresh +installs, and Socket Patch warns (`redirect_pypi_stale_install` / +`pypi_pipenv_stale_install`) when a venv still holds the upstream release, with the +remedy `pipenv run pip uninstall -y && pipenv sync`. Don't use `pipenv uninstall +` for this — it re-locks the patch away — and re-run `scan` after `pipenv lock` / +`pipenv update`, which regenerate the entry to its registry reference. Measured +boundaries are in [Pipenv compatibility](docs/testing/pipenv-compatibility.md). ## Common tasks ### Patch everything that can be patched ```bash -socket-patch scan # interactive: prompts before applying -socket-patch scan --json --mode agent --yes # non-interactive (CI, scripts) +socket-patch scan # hosted mode: rewrite lockfiles (never prompts) +socket-patch scan --dry-run # preview what it would change +socket-patch scan --json # same run, machine-readable result ``` -### Patch one specific CVE, advisory, or package +### Patch only some packages, or some projects in a monorepo ```bash -socket-patch get CVE-2024-12345 -socket-patch get GHSA-xxxx-yyyy-zzzz -socket-patch get lodash # fuzzy-matches installed packages -socket-patch get "pkg:npm/lodash@4.17.20" +socket-patch scan --package lodash --package pkg:pypi/requests # or --package lodash,requests +socket-patch scan apps/web apps/api # each PATH is a project directory +socket-patch scan 'services/*' # directory globs work too ``` -`socket-patch ` with a bare patch UUID is a shortcut for `get `. +`--package` takes a name (case-insensitive) or a purl with or without its version. In +hosted and vendored mode each PATH is a project directory, scanned as if it were +`--cwd` under an `== ==` header; the worst exit code wins. -### Keep patches applied across installs +### Patch one specific CVE or advisory ```bash -socket-patch setup # wire install hooks (npm postinstall, Python .pth, …) -socket-patch setup --check # CI gate: exit non-zero if hooks are missing or a patch drifted +socket-patch get CVE-2024-12345 --mode hosted +socket-patch get GHSA-xxxx-yyyy-zzzz --mode hosted ``` -See [`setup`](#setup) for what gets wired per ecosystem — and which ecosystems (Cargo, -Go, Maven, NuGet, Deno) have no hook and are patched on demand instead. +Like `scan`, `get` defaults to hosted mode; `--mode vendored` or `--mode agent` +(in-place apply, also implied by `--save-only` and `--global`) picks the others. -### Persist patches with no CI or install-hook changes (vendored / hosted) +### Check for patches in CI without changing anything ```bash -# Vendored: commit the patched packages themselves (airgap-friendly) -socket-patch scan --json --mode vendored --yes -git add .socket package-lock.json # your lockfile may differ - -# Hosted: smallest diff — patched deps resolve from patch.socket.dev -socket-patch scan --json --mode hosted --yes -git add .socket/vendor/redirect-state.json package-lock.json .npmrc # .npmrc: npm 12 allow-remote +socket-patch scan --json --dry-run | jq '{patches: .totalPatches, updates: (.updates | length)}' ``` -No `setup` hook or CI `apply` step is needed — the package manager installs the patched -bytes. See [Three patch modes](#three-patch-modes) to choose, and the -[mode × ecosystem matrix](docs/ecosystems.md#mode--ecosystem-matrix) for what your -ecosystem supports. - ### Run an auto-update bot in CI -One command discovers, applies, and garbage-collects in a single pass: - ```bash -socket-patch scan --json --mode agent --prune --yes +socket-patch scan --json ``` -The working-tree changes (the `.socket/` directory — plus lockfile edits if your bot -runs `--mode vendored` or `--mode hosted`) are what your PR tooling commits — e.g. -`peter-evans/create-pull-request` picks them up automatically; use the JSON summary for -the PR title/body. See [Scripting & CI/CD](#scripting--cicd), including how to supply -`SOCKET_API_TOKEN` for org-tier patches. +A hosted scan takes new patches and newer versions of the ones already applied. Your PR +tooling (e.g. `peter-evans/create-pull-request`) commits the changed lockfiles and +`.socket/vendor/`; use the JSON result for the PR title/body. See +[Scripting & CI/CD](#scripting--cicd), including how to supply `SOCKET_API_TOKEN` for +org-tier patches. ### Tell your vulnerability scanner about the patches @@ -420,61 +432,57 @@ socket-patch vex --output socket.vex.json grype --vex socket.vex.json # or trivy image --vex ... ``` -The OpenVEX document marks each patched CVE `not_affected`, so scanners stop flagging -vulnerabilities you've already remediated. You can also emit it inline from `apply` / -`scan` / `vendor` with `--vex `. Details in [OpenVEX -attestations](#openvex-attestations). +The OpenVEX document marks each patched CVE `not_affected`. Run it after installing, so +hosted patches are hash-verified against the installed copies. You can also emit it +inline with `scan --vex `. Details in [OpenVEX attestations](#openvex-attestations). ### Work offline / airgapped -Vendored mode needs no Socket infrastructure and no `socket-patch` binary at install -time — the patched packages install from the committed bytes (other, unvendored -dependencies still resolve from your registry or mirror as usual). Agent mode works -offline once the blobs are committed: - ```bash -socket-patch apply --offline # strict airgap: fails loudly if anything needs the network +socket-patch scan --mode vendored +git add .socket/vendor ``` -`scan` and `get` inherently need the network and refuse to run with `--offline`. +Vendored mode needs no Socket infrastructure and no `socket-patch` binary at install +time — the patched packages install from the committed bytes (other, unvendored +dependencies still resolve from your registry or mirror as usual). Agent mode also works +offline once its blobs are committed (`socket-patch apply --offline`). `scan` and `get` +need the network and refuse to run with `--offline`. ### Undo things -Five commands clean up different layers — the first three undo, the last two reconcile -and repair; pick by what you want back: - | Command | What it does | |---------|--------------| -| [`rollback`](#rollback) | **Fully unpatches, in every mode**: restores the original file bytes, unwinds vendored and hosted lockfile wiring, removes the rolled-back entries from the manifest (a zero-patch `{"patches": {}}` husk stays) and garbage-collects their blobs — everything, or just the given targets; `--preserve-state` keeps the local patch state for a later re-apply | -| [`remove`](#remove) | The single-patch dual of `rollback`: everything `rollback ` does for one PURL/UUID (restore, unwind its vendoring or hosted redirect, drop the entry, GC), plus `--skip-rollback` to drop only the record — **permanent**, the patch is fully gone in one command | -| [`vendor --revert`](#vendor) | **Un-vendors wholesale**: restores the recorded original lockfile fragments byte-for-byte and removes the `.socket/vendor/` artifacts — works without a manifest | -| [`scan --prune`](#scan) | **Reconciles, doesn't reverse**: drops manifest entries for packages that have left the project and garbage-collects orphan blob/diff/archive files — installed patches stay | +| [`rollback`](#rollback) | **Fully unpatches, in every mode**: unwinds hosted redirects (replaying the originals recorded in `redirect-state.json`) and vendored lockfile wiring, restores in-place files, and drops the records — everything, or just the given targets; `--preserve-state` keeps the local patch state for a later re-apply | +| [`remove`](#remove) | The single-patch form of `rollback`: restore, unwind, drop the record and GC for one PURL/UUID | +| [`vendor --revert`](#vendor) | **Un-vendors wholesale**: restores the recorded original lockfile fragments byte-for-byte and removes the `.socket/vendor/` artifacts | +| [`scan --prune`](#scan) | Agent mode: **reconciles, doesn't reverse** — drops manifest entries for packages that have left the project and garbage-collects orphan blob/diff/archive files | | [`repair`](#repair) (alias `gc`) | **Restores health, not originals**: re-downloads missing blobs, rebuilds missing/corrupt vendored artifacts, and cleans up unused ones | And `setup --remove` reverts the install hooks that `setup` added. -> Hosted mode is unwound by [`rollback`](#rollback), which replays the original -> lockfile / registry-config fragments recorded in `.socket/vendor/redirect-state.json` -> and drops the redirect records. If you revert a hosted edit by hand instead (e.g. -> `git checkout -- `), also delete that ledger — its recorded originals are -> then stale. (A leftover ledger no longer makes [`vex`](#vex) attest the removed -> redirects: a record attests only while a lockfile still wires its hosted patch, and is -> otherwise omitted as `redirect_unwired`.) +> If you revert a hosted edit by hand instead (e.g. `git checkout -- `), also +> delete `.socket/vendor/redirect-state.json` — its recorded originals are then stale. A +> leftover ledger does not make [`vex`](#vex) attest the removed redirects: a record +> attests only while a lockfile still wires its hosted patch. ## Command reference | Command | What it does | |---------|--------------| -| [`scan`](#scan) | Scan installed packages for available security patches | -| [`apply`](#apply) | Apply security patches from the local manifest | -| [`vex`](#vex) | Generate an OpenVEX attestation for the applied patches | -| [`vendor`](#vendor) | Eject patched dependencies into committable `.socket/vendor/` | -| [`setup`](#setup) | Wire install hooks so patches re-apply automatically | -| [`rollback`](#rollback) | Fully unpatch everything (or the given targets) in every mode and drop the rolled-back manifest entries (`--preserve-state` keeps them) | -| [`get`](#get) | Fetch and apply a patch by UUID / CVE / GHSA / PURL / name (alias: `download`) | -| [`list`](#list) | List recorded patches: manifest entries plus vendor-ledger and redirect-ledger records | -| [`remove`](#remove) | Remove a patch: roll back files + delete the manifest entry | -| [`repair`](#repair) | Download missing blobs, rebuild vendored artifacts, clean up unused ones (alias: `gc`) | +| [`scan`](#scan) | Find patches for your dependencies and apply them — by default by rewriting lockfiles to Socket-hosted patched packages | +| [`vex`](#vex) | Generate an OpenVEX document for the vulnerabilities the project's patches fix | +| [`vendor`](#vendor) | Eject patched dependencies into committable `.socket/vendor/` and rewire lockfiles to use them (`--revert` undoes it) | +| [`list`](#list) | List the patches in this project: hosted and vendored records plus any agent-mode manifest entries | +| **Agent mode (older commands)** | | +| [`get`](#get) | Fetch and apply one patch by UUID / CVE / GHSA / PURL / name (alias: `download`) | +| [`apply`](#apply) | Apply the patches in `.socket/manifest.json` in place | +| [`setup`](#setup) | Wire install hooks (npm, Python, Bundler, Composer) that re-apply patches after install | +| [`rollback`](#rollback) | Undo patches in every mode: restore original files and unwind hosted or vendored lockfile wiring | +| [`remove`](#remove) | Remove one patch by PURL or UUID (rolls back first) | +| [`repair`](#repair) | Download missing patch artifacts, rebuild vendored artifacts, clean up unused ones (alias: `gc`) | + +`socket-patch --update` updates the CLI itself (see [Updating](#updating)). ### Global options @@ -498,7 +506,7 @@ settings, described in [Configuration sources](#configuration-sources) below. | `--proxy-url ` | `SOCKET_PROXY_URL` | Public proxy URL used when no API token is set (default: `https://patches-api.socket.dev`). | | `-e, --ecosystems ` | `SOCKET_ECOSYSTEMS` | Restrict to specific ecosystems (comma-separated, e.g. `npm,pypi`). Unknown names are rejected. | | `--download-mode ` | `SOCKET_DOWNLOAD_MODE` | Artifact to fetch when local files are missing: `diff` (default, smallest delta) or `file` (legacy per-file blobs). | -| `--vendor-source ` | `SOCKET_VENDOR_SOURCE` | How `vendor` acquires the installable artifact: `auto` (default — download the prebuilt package from patch.socket.dev, fall back to a local build on any miss), `service` (require the service, fail-closed), or `build` (always build locally). Covers npm, pypi, cargo, golang, composer, gem, nuget, and maven. | +| `--vendor-source ` | `SOCKET_VENDOR_SOURCE` | How vendored mode acquires the installable artifact: `auto` (default — download the prebuilt package from patch.socket.dev, fall back to a local build on any miss), `service` (require the service, fail-closed), or `build` (always build locally). Covers npm, pypi, cargo, golang, composer, gem, nuget, and maven. | | `--vendor-url ` | `SOCKET_VENDOR_URL` | Base host for the vendoring service's package-reference request (default: the active `--api-url`/`--proxy-url` base). Point at staging / local dev for testing. | | `--patch-server-url ` | `SOCKET_PATCH_SERVER_URL` | Override the host of the prebuilt-archive download URL the service returns (default: as returned). Mainly for local-dev / testing. | | `--offline` | `SOCKET_OFFLINE` | Strict airgap: never contact the network. Operations that need remote data fail loudly. | @@ -509,10 +517,13 @@ settings, described in [Configuration sources](#configuration-sources) below. | `-v, --verbose` | `SOCKET_VERBOSE` | Show extra detail in human-readable output. | | `-s, --silent` | `SOCKET_SILENT` | Suppress non-error output. | | `--dry-run` | `SOCKET_DRY_RUN` | Preview the operation without making any mutations. | -| `-y, --yes` | `SOCKET_YES` | Skip interactive confirmation prompts. | +| `-y, --yes` | `SOCKET_YES` | Skip confirmation prompts (`get`, `rollback`, `remove`, `setup`, `--update`). `scan` never prompts, so it ignores this flag. | | `--lock-timeout ` | `SOCKET_LOCK_TIMEOUT` | Seconds to wait for `.socket/apply.lock` before giving up. `0`/unset = a single non-blocking try; a positive value retries with backoff. Only meaningful for the commands that take the lock — `apply`, `rollback`, `repair`, `remove`, `vendor`, `setup` (while persisting `--exclude`), and `scan`/`get` whenever they write (agent-mode download + apply, vendored, hosted). The lock file exists only while a command runs. | | `--debug` | `SOCKET_DEBUG` | Emit verbose debug logs to stderr. | | `--no-telemetry` | `SOCKET_TELEMETRY_DISABLED` | Disable anonymous usage telemetry. | +| `--no-npm-allow-remote-config` | `SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG` | Hosted mode: don't write `allow-remote=all` to the project `.npmrc` (see [npm compatibility](#npm-hosted-mode-and-npm-12)). | +| `--no-trust-lockfile-config` | `SOCKET_NO_TRUST_LOCKFILE_CONFIG` | Hosted mode: don't set `trustLockfile: true` in `pnpm-workspace.yaml` for a lockfileVersion 9 pnpm lock (pnpm 11+ then needs `pnpm install --trust-lockfile`). | +| `--no-vlt-install-cleanup` | `SOCKET_NO_VLT_INSTALL_CLEANUP` | Hosted mode: don't remove stale vlt installed copies after `vlt-lock.json` is repointed or restored (run `vlt ci` instead). | #### Configuration sources @@ -550,178 +561,123 @@ warning — it never breaks a command or pollutes `--json` output. `socket-patch cloned repo must never be able to redirect where patches come from or spend your token. (Full rationale: [docs/design/configuration.md](docs/design/configuration.md).) -One more env-only knob tunes *pacing* rather than routing. `scan` queries the patch API -with several requests in flight: against the authenticated endpoint, a quarter of the -requests a step has to make, between 8 and 32 (so a step with 128 or more requests runs -32 at once, one with 32 or fewer runs 8); against the public proxy, which shares one -server-side limit across anonymous callers, 4. The patch-record fetches behind `vex` and -`scan --vex` run up to 10 at once (4 on the proxy). -`SOCKET_API_CONCURRENCY=` overrides that, clamped to `1`-`32`; on the public proxy it -can only lower it. Set it when an endpoint in front of the API caps in-flight requests -per client — a self-hosted `--api-url`, a corporate reverse proxy, a WAF or a CDN — and -a scan starts reporting fewer patches than it should because some requests are being -rejected. `SOCKET_API_CONCURRENCY=1` sends one request at a time, the slowest and most -conservative setting. An unset, empty or non-numeric value leaves the defaults in place. - -A throttled patch API is retried, within bounds. An HTTP `429` or `503` answer to any -patch-API query (batch search, patch lists, patch views, VEX record fetches, hosted -package references) is retried up to 3 times, waiting as long as the server's -`Retry-After` asks (seconds or an HTTP date; a request asked to wait more than 30 s gives -up at once) or, without one, 0.5 s, 1 s, 2 s with jitter. All retries in one run must -finish within 60 s of the run's first retry (wall-clock: requests waiting in parallel -don't add up), so a heavily throttled run gives up instead of hanging. `SOCKET_API_MAX_RETRIES=` changes -the per-request count (`0`-`10`; `0` turns retries off). Other errors are never retried. -A query still throttled after its retries is reported, never dropped: a failed batch -prints `Warning: API batch of failed: …` (under `--json`, a top-level -`warnings[]` entry with code `api_batch_failed`), a failed patch-list lookup prints -`Warning: could not fetch details for : …` (`--json`: `patch_details_failed`), and -if every query fails the scan exits 1 with an error, as before. - -The crawl has a pacing knob too. Its directory walks (`node_modules`, and the Maven -repository with its POM parse) run on a small pool of threads: 4 by default (fewer on a machine with fewer performance cores), because the walk is bound -by the kernel's directory cache and more threads only add system time. -`SOCKET_WALK_THREADS=` overrides that, clamped to `1`-`16` and to the machine's CPU -count; an unset, empty or non-numeric value leaves the default in place. A soft open-file -limit below 128 still runs the walk on one thread, whatever the knob says. +Three env-only knobs tune pacing rather than routing: + +- `SOCKET_API_CONCURRENCY=` (clamped to `1`-`32`) caps in-flight patch-API requests. + By default `scan` runs a quarter of a step's requests at once, between 8 and 32, against + the authenticated API, and 4 against the public proxy (where the knob can only lower + it); `vex` record fetches run up to 10 (4 on the proxy). Lower it when a self-hosted + `--api-url`, corporate proxy, WAF or CDN caps requests per client and a scan starts + reporting fewer patches than it should. +- `SOCKET_API_MAX_RETRIES=` (`0`-`10`, default 3) sets how often a `429` / `503` + answer is retried. Retries honor `Retry-After` (a wait over 30 s gives up at once) or + back off 0.5 s, 1 s, 2 s with jitter, and all retries in a run must finish within 60 s. + Other errors are never retried. A query still failing is reported, never dropped + (`Warning: API batch of failed: …`, or `api_batch_failed` / + `patch_details_failed` in `--json` `warnings[]`); if every query fails the scan exits 1. +- `SOCKET_WALK_THREADS=` (clamped to `1`-`16` and the CPU count; default 4, fewer on small machines) sizes the + thread pool for the `node_modules` and Maven repository walks. A soft open-file limit + below 128 forces one thread. + +An unset, empty or non-numeric value leaves the default in place. The sections below list only each command's **command-specific** flags. ### `scan` -Scan installed packages for available security patches — and, with `--mode`, act on what -it finds. `scan` is the entry point for all three [patch modes](#three-patch-modes): +Find patches for your dependencies and apply them. `scan` is the entry point for all +three [patch modes](#three-patch-modes): -- `--mode agent` downloads and applies the selected patches in place; +- **hosted** (the default — a bare `scan`, `scan --json` included) rewrites lockfiles / + registry configs so only the patched dependencies resolve to Socket-hosted packages, + and records the edits in `.socket/vendor/redirect-state.json`; - `--mode vendored` discovers, downloads, and builds + wires the committable `.socket/vendor/` artifacts in one pass (re-vendoring automatically when a newer patch - is selected); it is manifest-free — the vendor ledger embeds the patch records and - nothing else is written under `.socket/`; -- `--mode hosted` rewrites lockfiles / registry configs so only the patched dependencies - resolve to Socket-hosted packages. - -Without a mode, interactive `scan` prompts before applying (in a TTY — when stdin is not a -TTY and neither `--yes` nor a mode/`--prune` flag is given, it is report-only: it prints what -it found plus the "To apply a single patch, run: …" hint, writes nothing, and exits 0), and -`scan --json` is read-only (discovery plus an `updates[]` array; no mutation). - -`scan --mode agent --prune` is the single command bots need for full auto-update: it -discovers patches, applies them, and garbage-collects orphan blob files plus manifest -entries for uninstalled packages — all in one invocation. + is selected), writing no `.socket/manifest.json`. It works on a fresh clone: + dependencies listed in the lockfile but not yet installed are fetched pristine from + their registry and integrity-verified against the lockfile before vendoring; +- `--mode agent` downloads the selected patches into `.socket/manifest.json` + blobs and + applies them to the installed files in place. + +`scan` never prompts, in any mode — `--yes` changes nothing. Use `--dry-run` to preview +any run. A `--prune` or `--global` / `--global-prefix` scan with no mode is the one +report-only case: it lists what it found (plus, with `--prune`, runs the agent-mode +garbage collection) and prints `To apply these patches in place, run: socket-patch scan +--mode agent [PATHS]`. Hosted mode cannot be combined with `--global`. + +When a package has several patches, `scan` applies the one described in +[Which patch is picked](#which-patch-is-picked). The JSON `updates[]` array lists +packages whose recorded patch has been superseded — agent manifest entries, vendored +entries, and hosted pins read from the redirect ledger and the lockfiles — and the next +`scan` in that mode takes the newer patch. **Usage:** ```bash -socket-patch scan [options] +socket-patch scan [PATHS]... [options] ``` +**Arguments:** +- `PATHS` — restrict the scan. In hosted and vendored mode each PATH (or directory glob, + e.g. `apps/*`) is a **project directory**, scanned on its own as if it were `--cwd`, + under an `== ==` header; the worst exit code wins. A PATH that is not a + directory exits 2, and `--json` accepts only one directory. In agent mode PATHS are + globs over **installed package paths** (a bare directory scopes its whole subtree; + `--prune` still considers the whole project, and lockfile-only packages are left out + with a warning). + **Command-specific options** (plus all [Global options](#global-options)): | Flag | Env var | Description | |------|---------|-------------| -| `--mode ` | — | Selects one of the three [patch modes](#three-patch-modes), summarized above. Combining `--mode` with a legacy boolean flag of a *different* mode is an error (exit 2); the same mode spelled both ways is accepted. | -| `--prune` | — | Garbage-collect after the scan: remove manifest entries for packages no longer present in the crawl (installed trees + lockfiles — a wiped `node_modules` alone doesn't prune lockfile-listed entries) and delete orphan blob/diff/package-archive files. Off by default. [Vendored](#vendor) packages are exempt from the crawl-based prune (an absent installed copy is their normal state), but a vendored entry whose dependency has left the lockfile is reverted (and any manifest entry it still had dropped). Orthogonal to `--mode` — combines with any mode. | -| `--detached` | — | Hidden compatibility no-op. Vendored mode is manifest-free by default: the vendor ledger (`.socket/vendor/state.json`) embeds the patch records and `.socket/manifest.json` is never written, so this former opt-in changes nothing. Still an error without `--mode vendored`. | +| `--mode ` | — | Selects one of the three [patch modes](#three-patch-modes) (default: `hosted`). Combining `--mode` with a legacy boolean flag of a *different* mode is an error (exit 2); the same mode spelled both ways is accepted. | +| `--package ` | `SOCKET_SCAN_PACKAGES` | Only scan these packages: a name (`lodash`, `@scope/pkg`, `requests`; case-insensitive) or a purl with or without its version (`pkg:npm/lodash`, `pkg:pypi/requests@2.31.0`). Repeat the flag or separate with commas. | +| `--prune` | — | Agent-mode garbage collection after the scan: remove manifest entries for packages no longer present in the crawl (installed trees + lockfiles — a wiped `node_modules` alone doesn't prune lockfile-listed entries) and delete orphan blob/diff/package-archive files. [Vendored](#vendor) packages are exempt from the crawl-based prune, but a vendored entry whose dependency has left the lockfile is reverted. Ignored, with a `redirect_prune_ignored` warning, in hosted mode; without a mode the scan is report-only. | +| `--sync` | — | Shorthand for `--mode agent --prune`: the one-flag agent-mode auto-update run. | | `--batch-size ` | `SOCKET_BATCH_SIZE` | Packages per API request (default: `500` on the authenticated API, `100` on the public proxy). A request whose body would exceed 256 KiB is split into smaller ones. | | `--all-releases` | `SOCKET_ALL_RELEASES` | Store patches for every release/distribution variant, not just the installed one — PyPI wheel/sdist, RubyGems platform, Maven classifier. Makes the manifest portable across environments (e.g. cross-platform CI caches). | -| `--vex ` | `SOCKET_VEX` | On a successful scan, also write an OpenVEX 0.2.0 document to this path. See [Inline VEX generation](#inline-vex-on-apply--scan--vendor). | +| `--vex ` | `SOCKET_VEX` | On a successful scan, also write an OpenVEX 0.2.0 document to this path. See [Inline VEX](#inline-vex-on-apply--scan--vendor). | | `--vex-product`, `--vex-no-verify`, `--vex-doc-id`, `--vex-compact` | `SOCKET_VEX_*` | Passthrough to the embedded VEX builder; mirror the standalone [`vex`](#vex) knobs. Inert unless `--vex` is set. | -> Deprecated boolean spellings of `--mode` remain supported for back-compat: `--apply` -> (== `--mode agent`) and `--vendor` (== `--mode vendored`); prefer `--mode`. `--sync` -> is not deprecated — it is convenience sugar for `--mode agent` + `--prune`, the -> single-flag bot invocation (`scan --json --sync --yes`). - -> Use `--dry-run` to preview what any moded run (with or without `--prune`) would do -> without mutating disk. +> Deprecated spellings: `--apply` (== `--mode agent`) and `--vendor` (== `--mode +> vendored`). `--detached` is a hidden no-op kept for compatibility (vendored mode is +> always manifest-free); it is still an error without vendored mode. **Examples:** ```bash -# Scan local project (interactive prompt to apply) +# Hosted mode (default): rewrite lockfiles to Socket-hosted patched packages socket-patch scan -# Scan with JSON output (discover + updates, no mutation) +# Same, JSON output socket-patch scan --json -# Agent mode: discover + apply patches in place (non-interactive) -socket-patch scan --json --mode agent --yes - -# Auto-update bot: discover, apply, garbage-collect — all in one -socket-patch scan --json --mode agent --prune --yes - -# Preview an agent-mode + prune run without mutating disk -socket-patch scan --json --mode agent --prune --yes --dry-run +# Preview without writing anything +socket-patch scan --json --dry-run -# Scan only npm packages +# Only npm packages / only one package socket-patch scan --ecosystems npm +socket-patch scan --package lodash -# Scan global packages -socket-patch scan -g - -# Agent mode + emit an OpenVEX attestation in one pass -socket-patch scan --json --mode agent --prune --yes --vex socket.vex.json - -# Vendored mode: build + commit every patched dependency (see the vendor -# command). Works on a completely fresh clone: dependencies listed in the -# lockfile but not yet installed are fetched pristine from their registry and -# integrity-verified against the lockfile before vendoring. -socket-patch scan --json --mode vendored --yes - -# Preview a vendored run (would_vendor / would_revendor / already_vendored) -socket-patch scan --json --mode vendored --yes --dry-run - -# Hosted mode: rewrite lockfiles so patched deps resolve to Socket-hosted -# integrity-pinned packages — no artifact bytes in the repo, no CI changes. -socket-patch scan --json --mode hosted --yes -``` - -> Already-vendored packages are **skipped by plain `--mode agent`** (the committed -> artifact is the patch); a newer available patch still appears in the JSON `updates[]` -> array — re-run `scan --mode vendored` to take it. -> -> Hosted-managed dependencies get the same signal: `updates[]` also consults the -> `.socket/vendor/redirect-state.json` ledger, so a superseded hosted patch shows up in -> read-only `scan --json` — re-run `scan --mode hosted` to take it. - -### `apply` - -Apply security patches from the local manifest. Idempotent — safe to run from install -hooks and CI on every build. - -**Usage:** -```bash -socket-patch apply [options] -``` - -**Command-specific options** (plus all [Global options](#global-options)): -| Flag | Env var | Description | -|------|---------|-------------| -| `-f, --force` | `SOCKET_FORCE` | Skip pre-application hash verification (apply even if package version differs). | -| `--check` | — | Read-only audit that the committed **Go** `replace`-redirects match the manifest (for CI / GitHub-App auditing) — Go only, since cargo patches in place and has no redirect to audit. Lock-free, crawl-free, and offline-safe: exits 0 in sync, 1 on drift. Vendored modules are excluded from the audit. | -| `--vex ` | `SOCKET_VEX` | On a successful apply, also write an OpenVEX 0.2.0 document to this path. See [Inline VEX generation](#inline-vex-on-apply--scan--vendor). | -| `--vex-product`, `--vex-no-verify`, `--vex-doc-id`, `--vex-compact` | `SOCKET_VEX_*` | Passthrough to the embedded VEX builder; mirror the standalone [`vex`](#vex) knobs. Inert unless `--vex` is set. | - -**Examples:** -```bash -# Apply patches -socket-patch apply +# Two projects of a monorepo +socket-patch scan apps/web apps/api -# Dry run -socket-patch apply --dry-run +# Vendored mode: build + commit every patched dependency +socket-patch scan --json --mode vendored -# Apply only npm patches -socket-patch apply --ecosystems npm +# Agent mode: patch installed files in place +socket-patch scan --json --mode agent -# Apply in offline mode -socket-patch apply --offline +# Agent-mode auto-update: discover, apply, garbage-collect +socket-patch scan --json --sync -# JSON output for CI/CD -socket-patch apply --json +# Report-only scan of global packages +socket-patch scan -g -# Apply and emit an OpenVEX attestation in one step -socket-patch apply --vex socket.vex.json +# Hosted mode + an OpenVEX attestation in one pass +socket-patch scan --vex socket.vex.json ``` -> Packages managed by [`vendor`](#vendor) are skipped (`skipped`/`vendored` in JSON): the -> committed vendored artifact is the patch, so there is nothing for `apply` to do — even -> when the installed tree (e.g. `node_modules/`) is absent. +> Already-vendored packages are **skipped by an agent-mode scan** (the committed +> artifact is the patch); a newer available patch still appears in `updates[]` — re-run +> `scan --mode vendored` to take it. ### `vex` @@ -761,17 +717,24 @@ socket-patch vex --no-verify --output socket.vex.json ### `vendor` -`apply`'s **committable** sibling — the standalone command behind -[vendored mode](#three-patch-modes) (`scan --mode vendored` runs discovery + this engine -in one pass). Instead of patching installed packages in place (machine-local state), -`vendor` ejects each patched package into `.socket/vendor///…` and -rewires your lockfile so the project consumes the vendored copy. Commit `.socket/vendor/` — -the vendored artifacts plus the ledger whose embedded patch records [`vex`](#vex), -[`list`](#list), and [`repair`](#repair) read (vendored mode writes nothing else under -`.socket/`; `vex` can also attest from the lockfile wiring alone) — along with the lockfile -edits, and **every fresh checkout -builds with the patched dependency**: no `socket-patch` binary, no Socket API access, no -install hook required on the consuming machine. +The command behind [vendored mode](#three-patch-modes). Instead of patching installed +packages in place, it ejects each patched package into +`.socket/vendor///…` and rewires your lockfile so the project +consumes the vendored copy. Commit `.socket/vendor/` (the artifacts plus the ledger whose +embedded patch records [`vex`](#vex), [`list`](#list) and [`repair`](#repair) read) +along with the lockfile edits, and **every fresh checkout builds with the patched +dependency**: no `socket-patch` binary, no Socket API access and no install hook on the +consuming machine. + +There are two ways in: + +- **`socket-patch scan --mode vendored`** discovers, downloads and vendors in one pass, + writes no `.socket/manifest.json`, and takes over packages a hosted scan redirected + (their hosted lockfile edits are reverted first). This is the way to move a hosted + project offline. +- **`socket-patch vendor`** vendors the agent-mode patches listed in + `.socket/manifest.json`. With no manifest (a hosted or `scan --mode vendored` project) + it has nothing to vendor and says so; `vendor --revert` works either way. Vendoring is per-patch: only dependencies with a Socket patch are vendored. For the lockfile flavors each ecosystem supports, see the @@ -780,6 +743,7 @@ lockfile flavors each ecosystem supports, see the **Usage:** ```bash socket-patch vendor [options] +socket-patch scan --mode vendored [PATHS]... [options] ``` **Command-specific options** (plus all [Global options](#global-options)): @@ -797,20 +761,26 @@ it: a vendor-owned tree or lockfile entry). - [`remove`](#remove) **reverts the vendoring** as part of removing the patch — lockfile restored, artifact deleted — so one command fully undoes it. -- [`scan`](#scan) skips downloading/applying patches for vendored packages, and - `--prune` exempts them from its crawl-based prune (though a vendored entry whose - dependency has left the lockfile is reverted and dropped); newer patches show up in - `updates[]` as the signal to re-run `scan --mode vendored`. +- An agent-mode [`scan`](#scan) skips vendored packages, and `--prune` exempts them + from its crawl-based prune (though a vendored entry whose dependency has left the + lockfile is reverted and dropped); newer patches show up in `updates[]` as the signal + to re-run `scan --mode vendored`. - [`vex`](#vex) attests vendored patches by verifying the **committed artifact** (marked `(vendored)` in the impact statement) — no `setup` install hook needed. -- Re-running `vendor` is idempotent. Standalone `vendor` (no flags) is driven by - `.socket/manifest.json` — patches dropped from that manifest are auto-reverted on the - next run — so on a project vendored by `scan --mode vendored` (no manifest) it is a - clean no-op; use [`repair`](#repair) to verify or rebuild the committed artifacts there. +- Re-running either form is idempotent. Patches dropped from `.socket/manifest.json` + are auto-reverted on the next `vendor` run; on a project vendored by + `scan --mode vendored`, use [`repair`](#repair) to verify or rebuild the committed + artifacts. **Examples:** ```bash -# Vendor every patched dependency listed in the manifest +# Discover, download and vendor every patchable dependency (takes over hosted redirects) +socket-patch scan --mode vendored + +# Preview it (would_vendor / would_revendor / already_vendored) +socket-patch scan --json --mode vendored --dry-run + +# Vendor the agent-mode patches listed in .socket/manifest.json socket-patch vendor # Preview without writing anything @@ -826,13 +796,169 @@ socket-patch vendor --revert socket-patch vendor --json ``` -> Prefer one command? [`scan --mode vendored`](#scan) discovers, downloads, *and* vendors -> in a single pass. +### `list` + +List the patches in this project: the hosted redirect ledger's records (labeled +`Mode: hosted`), the vendor ledger's (`Mode: vendored`), and any agent-mode entries in +`.socket/manifest.json`. + +**Usage:** +```bash +socket-patch list [options] +``` + +No command-specific options — see [Global options](#global-options) (`--json`, +`--manifest-path`, `--cwd` are the relevant ones). + +**Examples:** +```bash +# List patches +socket-patch list + +# JSON output +socket-patch list --json +``` + +**Sample output:** +``` +Found 1 patch: + +Package: pkg:npm/flatted@3.3.1 + UUID: 5cac955f-eab1-4d29-8f4f-c408a6cc9647 + Mode: hosted (recorded in .socket/vendor/redirect-state.json) + Tier: free + License: MIT + Exported: Wed, 18 Mar 2026 22:53:26 GMT + Vulnerabilities (1): + - GHSA-25h7-pfq9-p65f (CVE-2026-32141) + Severity: HIGH + Summary: flatted vulnerable to unbounded recursion DoS in parse() revive phase + Files patched (6): + - package/cjs/index.js + - package/es.js + ... +``` + +### Agent mode (older commands) + +Agent mode keeps patches in `.socket/manifest.json` + `.socket/blobs/` and edits the +installed files in place, so the CLI has to run again after every install. These +commands drive it. `rollback` also undoes hosted and vendored patches. + +### `get` + +Get one security patch from the Socket API and apply it. Accepts a UUID, CVE ID, GHSA +ID, PURL, or package name. The identifier type is auto-detected but can be forced with a +flag. Like `scan`, `get` defaults to hosted mode; pass `--mode vendored`, or +`--mode agent` for the manifest + in-place apply (implied by `--save-only` and +`--global`). When a package has +several patches, `get` picks the same one `scan` does (see +[Which patch is picked](#which-patch-is-picked)). Unlike `scan`, `get` prompts before +applying (`--yes` or a non-TTY stdin accepts). + +Alias: `download`. And as a shortcut, `socket-patch ` with a bare patch UUID is +rewritten to `socket-patch get `. + +**Usage:** +```bash +socket-patch get [options] +``` + +**Arguments:** +- `identifier` — patch UUID, CVE ID, GHSA ID, package PURL, or package name. Type is + auto-detected; force it with `--id` / `--cve` / `--ghsa` / `--package`. + +**Command-specific options** (plus all [Global options](#global-options)): +| Flag | Env var | Description | +|------|---------|-------------| +| `--id` | — | Force identifier to be treated as a UUID. | +| `--cve` | — | Force identifier to be treated as a CVE ID. | +| `--ghsa` | — | Force identifier to be treated as a GHSA ID. | +| `-p, --package` | — | Force identifier to be treated as a package name. | +| `--save-only` | `SOCKET_SAVE_ONLY` | Download the patch without applying it (alias: `--no-apply`). | +| `--one-off` | `SOCKET_ONE_OFF` | Reserved (hidden from `--help`): apply the patch immediately without saving to the `.socket` folder. **Not yet implemented** — the command currently errors up front. | +| `--all-releases` | `SOCKET_ALL_RELEASES` | Download patches for every release/distribution variant of a matched package (PyPI wheel/sdist, RubyGems platform, Maven classifier), not just the installed one. | +| `--mode ` | — | How to consume the patch; the same modes as `scan --mode` (default: `agent`). | + +> Authenticated lookups run against an org. The slug is auto-resolved from your token +> when omitted; pass `--org ` (or set `SOCKET_ORG_SLUG`) to pick one explicitly — +> useful when the token belongs to multiple orgs. + +**Examples:** +```bash +# Get patch by UUID +socket-patch get 550e8400-e29b-41d4-a716-446655440000 + +# Get patch by CVE +socket-patch get CVE-2024-12345 + +# Get patch by GHSA +socket-patch get GHSA-xxxx-yyyy-zzzz + +# Get patch by package name (fuzzy matches installed packages) +socket-patch get lodash + +# Consume the patch in hosted mode (lockfile rewrite) instead of in place +socket-patch get CVE-2024-12345 --mode hosted + +# Download only, don't apply +socket-patch get CVE-2024-12345 --save-only + +# Apply to global packages +socket-patch get lodash -g + +# JSON output for scripting +socket-patch get CVE-2024-12345 --json -y +``` + +### `apply` + +Apply the patches in `.socket/manifest.json` to the installed files in place. Idempotent — safe to run from install +hooks and CI on every build. + +**Usage:** +```bash +socket-patch apply [options] +``` + +**Command-specific options** (plus all [Global options](#global-options)): +| Flag | Env var | Description | +|------|---------|-------------| +| `-f, --force` | `SOCKET_FORCE` | Skip pre-application hash verification (apply even if package version differs). | +| `--check` | — | Read-only audit that the committed **Go** `replace`-redirects match the manifest (for CI / GitHub-App auditing) — Go only, since cargo patches in place and has no redirect to audit. Lock-free, crawl-free, and offline-safe: exits 0 in sync, 1 on drift. Vendored modules are excluded from the audit. | +| `--vex ` | `SOCKET_VEX` | On a successful apply, also write an OpenVEX 0.2.0 document to this path. See [Inline VEX generation](#inline-vex-on-apply--scan--vendor). | +| `--vex-product`, `--vex-no-verify`, `--vex-doc-id`, `--vex-compact` | `SOCKET_VEX_*` | Passthrough to the embedded VEX builder; mirror the standalone [`vex`](#vex) knobs. Inert unless `--vex` is set. | + +**Examples:** +```bash +# Apply patches +socket-patch apply + +# Dry run +socket-patch apply --dry-run + +# Apply only npm patches +socket-patch apply --ecosystems npm + +# Apply in offline mode +socket-patch apply --offline + +# JSON output for CI/CD +socket-patch apply --json + +# Apply and emit an OpenVEX attestation in one step +socket-patch apply --vex socket.vex.json +``` + +> Packages managed by [`vendor`](#vendor) are skipped (`skipped`/`vendored` in JSON): the +> committed vendored artifact is the patch, so there is nothing for `apply` to do — even +> when the installed tree (e.g. `node_modules/`) is absent. ### `setup` -Configure your project so patches are **re-applied automatically after install** — no -manual `socket-patch apply` step in CI. `setup` is a one-time operation: run it, commit +Agent mode only. Configure your project so in-place patches are **re-applied +automatically after install** — no manual `socket-patch apply` step in CI. (Hosted and +vendored projects need no hook: the lockfile already names the patched packages.) `setup` is a one-time operation: run it, commit the change together with your `.socket/` patches, and every later install handles the rest. It is strictly **opt-in** — nothing is hooked unless you run `setup` and commit the result. @@ -964,7 +1090,7 @@ entries, vendored artifacts + ledger entries) for a later re-apply; use [`remove`](#remove) for a single patch. A wet run confirms once (auto-accepted under `--yes`/`--json`/non-TTY). Vendor-owned purls -the run did NOT act on (today: a corrupt vendor ledger) are listed in the JSON output's +the run did NOT act on (a corrupt vendor ledger) are listed in the JSON output's `vendored` array; acted-on entries ride `vendoredReverted` / `vendoredPreserved` / `vendoredKept`. @@ -1001,105 +1127,6 @@ socket-patch rollback --dry-run socket-patch rollback --json ``` -### `get` - -Get a security patch from the Socket API and apply it. Accepts a UUID, CVE ID, GHSA ID, -PURL, or package name. The identifier type is auto-detected but can be forced with a -flag. - -Alias: `download`. And as a shortcut, `socket-patch ` with a bare patch UUID is -rewritten to `socket-patch get `. - -**Usage:** -```bash -socket-patch get [options] -``` - -**Arguments:** -- `identifier` — patch UUID, CVE ID, GHSA ID, package PURL, or package name. Type is - auto-detected; force it with `--id` / `--cve` / `--ghsa` / `--package`. - -**Command-specific options** (plus all [Global options](#global-options)): -| Flag | Env var | Description | -|------|---------|-------------| -| `--id` | — | Force identifier to be treated as a UUID. | -| `--cve` | — | Force identifier to be treated as a CVE ID. | -| `--ghsa` | — | Force identifier to be treated as a GHSA ID. | -| `-p, --package` | — | Force identifier to be treated as a package name. | -| `--save-only` | `SOCKET_SAVE_ONLY` | Download the patch without applying it (alias: `--no-apply`). | -| `--one-off` | `SOCKET_ONE_OFF` | Reserved (hidden from `--help`): apply the patch immediately without saving to the `.socket` folder. **Not yet implemented** — the command currently errors up front. | -| `--all-releases` | `SOCKET_ALL_RELEASES` | Download patches for every release/distribution variant of a matched package (PyPI wheel/sdist, RubyGems platform, Maven classifier), not just the installed one. | - -> Authenticated lookups run against an org. The slug is auto-resolved from your token -> when omitted; pass `--org ` (or set `SOCKET_ORG_SLUG`) to pick one explicitly — -> useful when the token belongs to multiple orgs. - -**Examples:** -```bash -# Get patch by UUID -socket-patch get 550e8400-e29b-41d4-a716-446655440000 - -# Get patch by CVE -socket-patch get CVE-2024-12345 - -# Get patch by GHSA -socket-patch get GHSA-xxxx-yyyy-zzzz - -# Get patch by package name (fuzzy matches installed packages) -socket-patch get lodash - -# Download only, don't apply -socket-patch get CVE-2024-12345 --save-only - -# Apply to global packages -socket-patch get lodash -g - -# JSON output for scripting -socket-patch get CVE-2024-12345 --json -y -``` - -### `list` - -List all patches recorded locally: the manifest's entries plus the vendor ledger's -(labeled `Mode: vendored`) and the hosted redirect ledger's records, so it works on -manifest-less vendored or hosted projects too. - -**Usage:** -```bash -socket-patch list [options] -``` - -No command-specific options — see [Global options](#global-options) (`--json`, -`--manifest-path`, `--cwd` are the relevant ones). - -**Examples:** -```bash -# List patches -socket-patch list - -# JSON output -socket-patch list --json -``` - -**Sample output:** -``` -Found 1 patch: - -Package: pkg:npm/flatted@3.3.1 - UUID: 5cac955f-eab1-4d29-8f4f-c408a6cc9647 - Tier: free - License: MIT - Exported: Wed, 18 Mar 2026 22:53:26 GMT - Vulnerabilities (1): - - GHSA-25h7-pfq9-p65f (CVE-2026-32141) - Severity: HIGH - Summary: flatted vulnerable to unbounded recursion DoS in parse() revive phase - Files patched (6): - - package/cjs/index.js - - package/es.js - ... -``` - ### `remove` Remove a patch from the manifest (rolls back files first by default). If the package is @@ -1120,6 +1147,7 @@ socket-patch remove [options] **Command-specific options** (plus all [Global options](#global-options)): | Flag | Env var | Description | |------|---------|-------------| +| `--preserve-state` | `SOCKET_PRESERVE_STATE` | Restore the files and lockfiles but keep the patch's local state (manifest entry, vendored artifact + ledger entry) for a later re-apply, and skip blob cleanup — the single-patch twin of `rollback --preserve-state`. Conflicts with `--skip-rollback`. | | `--skip-rollback` | `SOCKET_SKIP_ROLLBACK` | Only update the manifest, do not restore original files (for a vendored package that still has a manifest entry this also leaves the vendor wiring + artifact in place; refused for manifest-less vendored patches, where the revert *is* the removal). | **Examples:** @@ -1147,8 +1175,7 @@ Alias: `gc` `repair` cleans up the `.socket/` directory without running a scan — useful when you've manually adjusted the manifest, recovered from a partial-failure state, or just want to free space. It also rebuilds missing or corrupt vendored artifacts. For the combined -workflow (discover + apply + GC in one pass), use -`scan --json --mode agent --prune --yes` instead. +agent-mode workflow (discover + apply + GC in one pass), use `scan --sync` instead. Like every other mutating command, `repair` takes the `.socket/apply.lock` advisory lock while it runs and removes it when it finishes. If another `socket-patch` process is @@ -1226,7 +1253,7 @@ Each statement's impact string records *how* the patch is persisted — one mark |---|---|---|---| | `Patched via Socket patch ` | agent | The installed tree: every patched file's hash was verified against the manifest's `afterHash` | Trust the statement as long as the agent install hook (or a CI `apply`) keeps re-applying; ecosystems without a hook must be declared in `setup.manual` | | `Patched via Socket patch (vendored)` | vendored | The **committed** `.socket/vendor/` artifact was hash-verified — no install hook needed; the lockfile wiring is the persistence mechanism | Trust it on any checkout; the committed bytes are the patch | -| `Patched via Socket patch (redirected)` | hosted | The lockfile's integrity pin points at the Socket-hosted patched package. A post-install `socket-patch vex` hash-verifies the installed copy; before any install it attests from the pin. When emitted in-run by `scan --mode hosted --vex`, the statement is attested **without hash verification** (the bytes are fetched at install time — the JSON `vex` summary carries `verified: false`) | Ensure installs still resolve from `patch.socket.dev` (the lockfile edit is intact), and run `socket-patch vex` **after installing** to have the redirected patches hash-verified against the installed tree | +| `Patched via Socket patch (redirected)` | hosted | The lockfile's integrity pin points at the Socket-hosted patched package. A post-install `socket-patch vex` hash-verifies the installed copy; before any install it attests from the pin. When emitted in-run by a hosted `scan --vex`, the statement is attested **without hash verification** (the bytes are fetched at install time — the JSON `vex` summary carries `verified: false`) | Ensure installs still resolve from `patch.socket.dev` (the lockfile edit is intact), and run `socket-patch vex` **after installing** to have the redirected patches hash-verified against the installed tree | The markers are stable strings (see [CLI_CONTRACT.md](crates/socket-patch-cli/CLI_CONTRACT.md)); scanners and policy engines @@ -1256,7 +1283,7 @@ grype --vex socket.vex.json trivy image --vex socket.vex.json ``` -Apply patches first (in any mode). When nothing names a patch anywhere — no manifest +Patch first (in any mode). When nothing names a patch anywhere — no manifest entry, no `.socket/vendor` ledger entry, no hosted or vendored lockfile reference — `vex` errors with `no_patches` (exit 1) when the manifest file exists but is empty, or with `manifest_not_found` (exit 2) when there is no manifest either. When there are patches but @@ -1267,7 +1294,7 @@ with its reason: `hash_mismatch`, `record_unavailable`, `redirect_unwired`, and A hosted or vendored checkout needs no `.socket/manifest.json`, and no `.socket/vendor` ledgers either. This covers a depscan-opened PR, a clone of a repo that never committed its -ledgers, and a `scan --mode hosted` run. `vex` reads the patch reference out of each root +ledgers, and a fresh hosted `scan`. `vex` reads the patch reference out of each root lockfile or config: a `patch.socket.dev` URL or a `.socket/vendor///…` path carries the patch uuid. It then finds that patch's record in the manifest or ledgers, or fetches it from the patch API. The references it accepts are what socket-patch's own @@ -1321,14 +1348,15 @@ You don't need a separate `vex` invocation: pass `--vex ` to `apply`, `sca `vendor` and the same OpenVEX document is generated as a side-effect of a successful run. ```bash -# Patch and attest in one step -socket-patch apply --vex socket.vex.json - -# Discover, apply, prune, and attest — the full auto-update-bot pass -socket-patch scan --json --mode agent --prune --yes --vex socket.vex.json +# Hosted scan and attest in one step (not hash-verified until installed: re-run `vex` then) +socket-patch scan --vex socket.vex.json # Vendor and attest — manifest-less by construction -socket-patch scan --json --mode vendored --yes --vex socket.vex.json +socket-patch scan --json --mode vendored --vex socket.vex.json + +# Agent mode: patch in place and attest +socket-patch apply --vex socket.vex.json +socket-patch scan --json --sync --vex socket.vex.json ``` The `--vex-product`, `--vex-no-verify`, `--vex-doc-id`, and `--vex-compact` flags mirror @@ -1340,10 +1368,10 @@ Contract: with the command's own `--json` output. JSON mode adds a top-level `vex` summary — `{ path, statements, format }` — to the envelope (`apply`) / result (`scan`). - It's built from the project **as it stands after the run** — the manifest (including - any `--mode agent` writes, with or without `--prune`), the `.socket/vendor` ledgers, and - the lockfile wiring — and verified against on-disk state unless `--vex-no-verify` is set. - Generated for real applies and read-only scans alike; `--dry-run` skips it (nothing was - changed, so nothing is attested). + any `--mode agent` writes), the `.socket/vendor` ledgers, and the lockfile wiring — and + verified against on-disk state unless `--vex-no-verify` is set. Generated for real runs + and report-only scans alike; `--dry-run` skips it (nothing was changed, so nothing is + attested). - `apply --vex` and `vendor --vex` with **no manifest** still attest what the lockfiles and ledgers wire. A project with nothing wired anywhere keeps the calm exit 0 and writes no document. `apply --check` never generates one. @@ -1365,37 +1393,38 @@ anonymous free tier, set `SOCKET_NO_API_TOKEN=1`. See [Configuration sources](#configuration-sources). ```bash -# Check for available patches in CI (read-only) -result=$(socket-patch scan --json --ecosystems npm) -patches=$(echo "$result" | jq '.totalPatches') +# Read-only check: what would a hosted scan patch? (writes nothing) +result=$(socket-patch scan --json --dry-run --ecosystems npm) +echo "$result" | jq '{patches: .totalPatches, redirected: .redirect.redirected, updates: (.updates | length)}' -# Auto-update bot: discover, apply, and garbage-collect in one pass -socket-patch scan --json --mode agent --prune --yes | jq '{ +# Auto-update bot (hosted): take new and newer patches, then let the PR action commit +socket-patch scan --json | jq '{redirected: .redirect.redirected, files: .redirect.rewrittenFiles}' +# The PR action (e.g. peter-evans/create-pull-request) commits the working-tree +# changes (lockfiles + .socket/vendor/); use this summary as the PR body. + +# Agent-mode bot: discover, apply, and garbage-collect in one pass +socket-patch scan --json --sync | jq '{ applied: [.apply.patches[]? | select(.action == "added" or .action == "updated") | .purl], pruned: (.gc.prunedManifestEntries // []), bytes_freed: (.gc.bytesFreed // 0) }' -# The PR action (e.g. peter-evans/create-pull-request) commits the working-tree -# changes; use this summary as the PR body. -# Apply patches and check result +# Agent mode: re-apply committed patches and check the result socket-patch apply --json | jq '.status' # "success", "partialFailure", "noManifest", or "error" ``` -When stdin is not a TTY (e.g. in CI pipelines), interactive prompts auto-proceed instead -of blocking — with one deliberate exception: a plain `scan` (no `--mode`/`--apply`/`--sync`/ -`--vendor`/`--prune` and no `--yes`) is report-only there. It prints what it found and the -"To apply a single patch, run: …" hint, writes nothing, and exits 0; add `--yes` or a mode flag -to mutate. Progress indicators and ANSI colors are automatically suppressed when output -is piped. +`scan` never prompts, so CI needs no `--yes` for it. The commands that do confirm +(`get`, `rollback`, `remove`, `setup`) auto-proceed when stdin is not a TTY. Progress +indicators and ANSI colors are automatically suppressed when output is piped. The exact JSON shapes, exit codes, and stability guarantees are specified in [CLI_CONTRACT.md](crates/socket-patch-cli/CLI_CONTRACT.md). ## Manifest format -Downloaded patches are stored in `.socket/manifest.json`: +Agent mode records downloaded patches in `.socket/manifest.json` (hosted and vendored +mode never write it): ```json { diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 5901563d..55a3983a 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -2,22 +2,24 @@ This document defines the **public surface** of the `socket-patch` binary. Anything listed here is part of the user-visible contract: third-party scripts, CI pipelines, and the npm/pypi/cargo wrappers depend on it. Changes are governed by the semver policy at the bottom of this file. -> **Why this exists.** Until late 2026 the CLI crate had zero unit tests under `src/` — only network-dependent `tests/e2e_*.rs` suites that run with `--ignored`. A flag rename, a default-value change, or a JSON key rename could land green and break every shipped wrapper silently. The contract below is now backed by the unit tests under `crates/socket-patch-cli/src/**` (`#[cfg(test)] mod tests`) and the parser tests under `crates/socket-patch-cli/tests/cli_parse_*.rs`. Changes that violate the contract must update those tests in lock-step with a major version bump. +> **Why this exists.** A flag rename, a default-value change, or a JSON key rename can land green and break every shipped wrapper silently. The contract below is backed by the unit tests under `crates/socket-patch-cli/src/**` (`#[cfg(test)] mod tests`) and the parser tests under `crates/socket-patch-cli/tests/cli_parse_*.rs`. Changes that violate the contract must update those tests in lock-step with a major version bump. ## Subcommands | Name | Visible alias(es) | Notes | |---|---|---| -| `scan` | — | Crawl installed packages for available patches | -| `apply` | — | Apply patches from the local manifest | +| `scan` | — | Find patches for installed packages and apply them. **v5.0 (MAJOR)**: a bare `scan` runs hosted mode (rewrites lockfiles so only the patched dependencies resolve to Socket-hosted, integrity-pinned packages); `--mode vendored` / `--mode agent` pick the other modes. Never prompts. See [scan modes](#scan-modes-v50) | | `vex` | — | Emit an OpenVEX 0.2.0 attestation derived from the local manifest, the `.socket/vendor` ledgers, and the hosted / vendored patch references the project's lockfiles wire (no manifest required) | | `vendor` | — | Eject patched dependencies into committable `.socket/vendor/` and rewire lockfiles | -| `setup` | — | Wire automatic-patching install hooks (npm/pypi/gem) | -| `rollback` | — | **Full-state rollback (v5.0, MAJOR)**: restore original files AND unwind vendored/hosted lockfile wiring, remove the rolled-back entries from the manifest, and GC their blobs/archives; takes optional variadic positional `targets` (PURL \| UUID \| path glob). See [Rollback command contract](#rollback-command-contract-v50) | -| `get` | `download` | Fetch + apply patch; requires positional `identifier` | | `list` | — | Print patches in the local manifest, plus the vendor ledger's (v5.0) and the hosted redirect ledger's records (labeled; see the `manifest_not_found` row and the action matrix) | -| `remove` | — | Remove patch from manifest (rolls back first); requires positional `identifier` | -| `repair` | `gc` | Download missing blobs, rebuild missing/corrupt vendored artifacts, and clean up unused ones (refuses with `lock_held` when a live process holds the lock; see "Lock lifecycle" below) | +| `get` | `download` | Agent mode by default (`--mode` selects hosted/vendored): fetch + apply a patch; requires positional `identifier` | +| `apply` | — | Agent mode: apply patches from the local manifest | +| `setup` | — | Agent mode: wire automatic-patching install hooks (npm/pypi/gem/composer) | +| `rollback` | — | **Full-state rollback (v5.0, MAJOR)**: restore original files AND unwind vendored/hosted lockfile wiring, remove the rolled-back entries from the manifest, and GC their blobs/archives; takes optional variadic positional `targets` (PURL \| UUID \| path glob). See [Rollback command contract](#rollback-command-contract-v50) | +| `remove` | — | Agent mode: remove a patch from manifest (rolls back first); requires positional `identifier` | +| `repair` | `gc` | Agent mode: download missing blobs, rebuild missing/corrupt vendored artifacts, and clean up unused ones (refuses with `lock_held` when a live process holds the lock; see "Lock lifecycle" below) | + +Rows are in `--help` order (v5.0): the hosted/vendored workflow (`scan` → `vex` → `vendor`, with `list` to inspect), then the agent-mode (in-place patching) commands. **Removed in v4.0:** the `unlock` subcommand (a leftover lock from a crashed run never blocks acquisition — the OS releases a dead holder's advisory lock — so there is no stale-lock state to inspect or clear before a mutating command; `repair` briefly owned lock-file cleanup in v4.x, and since v5.0 every lock-taking command removes its own lock file on exit). @@ -54,7 +56,7 @@ In v3.0 every subcommand accepts the same set of "global" flags via a single sha | `--verbose` | `-v` | `SOCKET_VERBOSE` | `false` | bool | Extra detail | | `--silent` | `-s` | `SOCKET_SILENT` | `false` | bool | Errors only | | `--dry-run` | — | `SOCKET_DRY_RUN` | `false` | bool | Preview, no mutations (a dry run may still take the transient `apply.lock`, removed again on exit — see "Lock lifecycle"; hosted and vendored previews never leave a `.socket/`) | -| `--yes` | `-y` | `SOCKET_YES` | `false` | bool | Skip prompts | +| `--yes` | `-y` | `SOCKET_YES` | `false` | bool | Skip prompts (`scan` never prompts) | | `--lock-timeout` | — | `SOCKET_LOCK_TIMEOUT` | (none) | seconds (u64) | How long to wait for `<.socket>/apply.lock`. Unset and `0` both mean a single non-blocking try; a positive value retries with a 100 ms backoff. Only meaningful on the lock-taking subcommands — `apply`, `rollback`, `repair`, `remove`, `vendor`, `setup` (while persisting `--exclude`), and `scan`/`get` whenever they write (agent-mode download + apply, vendored, hosted) | | `--debug` | — | `SOCKET_DEBUG` | `false` | bool | Verbose debug logs to stderr | | `--no-telemetry` | — | `SOCKET_TELEMETRY_DISABLED` | `false` | bool | Disable anonymous usage telemetry | @@ -62,7 +64,7 @@ In v3.0 every subcommand accepts the same set of "global" flags via a single sha | `--no-npm-allow-remote-config` | — | `SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG` | `false` | bool | Opt out of hosted mode's automatic `allow-remote=all` write to the project `.npmrc` (see the npm allow-remote note under the scan arguments). Read by `scan --mode hosted` and `get --mode hosted`; other subcommands accept it silently | | `--no-vlt-install-cleanup` | — | `SOCKET_NO_VLT_INSTALL_CLEANUP` | `false` | bool | Opt out of hosted mode's warm-tree heal for vlt: stale installed copies (`node_modules/.vlt-lock.json` and the stale `node_modules/.vlt/` entries) are left in place after `vlt-lock.json` is repointed (`scan`/`get --mode hosted`) or restored (`rollback`/`remove`), and the `redirect_vlt_reinstall_required` advisory tells you to run `vlt ci` instead. Stale copies of optional dependencies are always left in place (see `redirect_vlt_reinstall_required`). Other subcommands accept it silently | -The `--offline` semantics unified in v3.0. Previously `apply` enforced strict airgap, `repair` skipped network ops, and `rollback` failed when blobs were missing. All three now mean the same thing: never contact the network, fail loudly when a required local source is missing. On `repair`, `--offline` and `--download-only` are mutually exclusive (exit 2). `scan` and `get` need remote data for their core function (patch discovery / patch fetch), so `--offline` refuses them up front — exit 1 with an error naming the offline gate (JSON: `status: "error"`), before any crawl, client build, or network contact. This covers `scan --vendor` too: offline vendored staging is `vendor --offline`'s job. +`--offline` means the same thing on every command (v3.0): never contact the network, fail loudly when a required local source is missing. On `repair`, `--offline` and `--download-only` are mutually exclusive (exit 2). `scan` and `get` need remote data for their core function (patch discovery / patch fetch), so `--offline` refuses them up front — exit 1 with an error naming the offline gate (JSON: `status: "error"`), before any crawl, client build, or network contact. This covers `scan --vendor` too: offline vendored staging is `vendor --offline`'s job. The `--strict` mismatch policy applies to the in-place apply paths (apply/get/scan --apply/hook/go redirect). DEFAULT (v3.4): a file whose on-disk content matches neither the patch's beforeHash nor its afterHash is overwritten with the FULL verified patched content (the diff strategy self-disables on a wrong base; archive/blob writes are hash-gated to exactly afterHash; the missing blob is downloaded on demand) and surfaced as a `content_mismatch_overwritten` stderr warning + Skipped event. `--strict` turns that case into a hard error. `--force` overrides `--strict` and additionally skips missing files. Vendor staging is unaffected (it always auto-overwrites into its private stage). @@ -78,14 +80,15 @@ Beyond the globals above, each subcommand defines a small set of local arguments | `vendor` | `--revert` | `SOCKET_VENDOR_REVERT` | Undo vendoring: restore recorded original lockfile fragments + remove `.socket/vendor/` artifacts. Works without a manifest | | `apply`, `scan`, `vendor` | `--vex` | `SOCKET_VEX` | Generate an OpenVEX 0.2.0 document at this path on a successful run; see "embedded VEX" below | | `apply`, `scan`, `vendor` | `--vex-product`, `--vex-no-verify`, `--vex-doc-id`, `--vex-compact` | `SOCKET_VEX_PRODUCT`, `SOCKET_VEX_NO_VERIFY`, `SOCKET_VEX_DOC_ID`, `SOCKET_VEX_COMPACT` | Passthrough to the embedded VEX builder; mirror the standalone `vex` knobs. Inert unless `--vex` is set | -| `scan` | positional `[PATHS]...` | — | (v5.0) Optional path globs scoping DISCOVERY to packages installed under matching paths (`packages/foo`, `apps/**`). Purl-level: a package is in scope when ANY of its installed copies sits under a matching path. Rejected with `--mode hosted`/`--mode vendored` (exit 2, `resolve_mode_flags` — their lockfile rewiring is whole-project by construction); combines with `--apply`/`--sync`/`--prune`. See "Path-scoped scans" below | -| `scan` | `--mode ` | — | The documented selector for the three patch-application modes. Each value is equivalent to one legacy boolean spelling: `hosted` == `--redirect`, `vendored` == `--vendor`, `agent` == `--apply` (`--sync` counts as an agent spelling). Combining `--mode` with a boolean of a DIFFERENT mode is a usage error (exit 2, enforced in `resolve_mode_flags` — clap's `conflicts_with` is value-independent); the same mode spelled both ways is accepted. `--prune` is an orthogonal GC knob and never conflicts — but hosted mode runs no GC, so `--mode hosted --prune` emits an explicit `redirect_prune_ignored` warning (JSON `redirect.warnings[]` + stderr) instead of silently dropping the flag | -| `scan` | `--redirect` | — | Hosted mode's legacy boolean spelling (**hidden from `--help`** and **deprecated** — `--mode hosted` is the documented spelling; this alias is scheduled for removal in v4): rewrite lockfiles / registry configs so ONLY the patched dependencies resolve to Socket's hosted patch server; no artifact bytes land in the repo. Conflicts with `--apply`/`--sync`/`--vendor` | -| `scan` | `--apply` / `--prune` / `--sync` | — | Mode selectors (sync = apply + prune); `--apply` == `--mode agent` | +| `scan` | positional `[PATHS]...` | — | (v5.0) Meaning depends on the mode. **Hosted / vendored** (bare `scan` included): each PATH, or directory glob (`apps/*`), is a project directory scanned on its own as if it were `--cwd`. **Agent** (and a mode-less `--prune`/`--global` report): path globs scoping DISCOVERY to packages installed under matching paths (`packages/foo`, `apps/**`). See "Path-scoped scans" below | +| `scan` | `--mode ` | — | The documented selector for the three patch-application modes (v5.0 default: `hosted`, except that a `--prune` or `--global`/`--global-prefix` scan with no mode is report-only). Hidden value aliases: `host`/`redirect` (hosted), `vendor` (vendored). Each value is equivalent to one legacy boolean spelling: `hosted` == `--redirect`, `vendored` == `--vendor`, `agent` == `--apply` (`--sync` counts as an agent spelling). Combining `--mode` with a boolean of a DIFFERENT mode is a usage error (exit 2, enforced in `resolve_mode_flags` — clap's `conflicts_with` is value-independent); the same mode spelled both ways is accepted. `--prune` is an orthogonal GC knob and never conflicts — but hosted mode runs no GC, so `--mode hosted --prune` emits an explicit `redirect_prune_ignored` warning (JSON `redirect.warnings[]` + stderr) instead of silently dropping the flag | +| `scan` | `--redirect` | — | Hosted mode's legacy boolean spelling (**hidden from `--help`** and **deprecated** — `--mode hosted` is the documented spelling): rewrite lockfiles / registry configs so ONLY the patched dependencies resolve to Socket's hosted patch server; no artifact bytes land in the repo. Conflicts with `--apply`/`--sync`/`--vendor` | +| `scan` | `--apply` / `--prune` / `--sync` | — | `--apply` == `--mode agent` (deprecated spelling); `--prune` = GC after the scan (ignored with a `redirect_prune_ignored` warning in hosted mode); `--sync` = `--mode agent --prune` | +| `scan` | `--package ` (repeatable or comma-separated) | `SOCKET_SCAN_PACKAGES` | (v5.0) Only scan these packages: a name (`lodash`, `@scope/pkg`, `requests`, `group:artifact`; matched against the full name or its last segment, case-insensitively) or a purl with or without a version (`pkg:npm/lodash` matches every version, `pkg:pypi/requests@2.31.0` only that one). Qualifiers are ignored. Filters the crawl like `--ecosystems`, after the prune universe is captured, so `--prune` still judges the full crawl | | `scan` | `--vendor` / `--detached` | — | Vendor every patched dependency instead of applying in place (`--vendor` == `--mode vendored`; conflicts with `--apply`/`--sync`, combines with `--prune`). Vendored mode is manifest-free (v5.0): the vendor ledger embeds the patch records and `.socket/manifest.json` is never written. `--detached` — the former opt-in for exactly that — is **hidden** and retained for compatibility as a no-op; it is still a usage error (exit 2) without vendored mode in either spelling | | `scan` | `--batch-size` | `SOCKET_BATCH_SIZE` | API batch chunk size. Unset (v5.0): `500` on the authenticated API (the server's per-request maximum), `100` on the public proxy; a given value applies on either endpoint (`0` is floored to `1`). A chunk whose request body would exceed 256 KiB (the public proxy's body cap) is split into consecutive smaller chunks, deterministically (greedy, in crawl order). A mid-run downgrade to the proxy keeps the chunks already formed | | `get`, `scan` | `--all-releases` | `SOCKET_ALL_RELEASES` | Download patches for every release/distribution variant of a matched package — PyPI wheel/sdist (`artifact_id`), RubyGems (`platform`), Maven (`classifier`) — not just the one(s) matching the locally-installed distribution. On `scan` this makes the stored manifest portable across environments (e.g. cross-platform CI caches). On `get` (v3.6) it ALSO disables the coarse installed-**version** narrowing of CVE/GHSA fan-outs (see "get --mode and installed narrowing"): every found version's patch is fetched, installed or not | -| `get` | positional `identifier`; `--id` / `--cve` / `--ghsa` / `--package` (`-p`); `--save-only` (alias `--no-apply`); `--one-off` (hidden from `--help`: always fails "not yet implemented"); `--mode ` | `SOCKET_SAVE_ONLY`, `SOCKET_ONE_OFF` | Patch lookup + consumption mode (v3.6). `--mode` reuses scan's value enum (same hidden value aliases `host`/`redirect`/`vendor`; deliberately no env binding, matching scan). Default `agent` = today's save+apply flow, unchanged. `--save-only` conflicts with `--mode hosted\|vendored` — rejected with **exit 1** via get's established self-enforced-conflict style (unlike scan's exit-2 mode conflicts; see the exit-code table) | +| `get` | positional `identifier`; `--id` / `--cve` / `--ghsa` / `--package` (`-p`); `--save-only` (alias `--no-apply`); `--one-off` (hidden from `--help`: always fails "not yet implemented"); `--mode ` | `SOCKET_SAVE_ONLY`, `SOCKET_ONE_OFF` | Patch lookup + consumption mode (v3.6). `--mode` reuses scan's value enum (same hidden value aliases `host`/`redirect`/`vendor`; deliberately no env binding, matching scan). Default (v5.0): `hosted`, like scan; `agent` (save + apply in place) when `--save-only` or `--global`/`--global-prefix` is given. An explicit `--save-only` conflicts with `--mode hosted\|vendored` — rejected with **exit 1** via get's established self-enforced-conflict style (unlike scan's exit-2 mode conflicts; see the exit-code table) | | `remove` | positional `identifier`; `--skip-rollback`; `--preserve-state` (v5.0) | `SOCKET_SKIP_ROLLBACK`, `SOCKET_PRESERVE_STATE` | Manifest entry removal. `--preserve-state` is the single-patch twin of `rollback --preserve-state`: restore the tree and unwind the identifier's vendored/hosted wiring, but keep the manifest entry, the vendored artifact + ledger entry, and skip all GC. Combining it with `--skip-rollback` is a self-enforced usage error (exit 2): one flag keeps the tree and drops the state, the other restores the tree and keeps the state — together they select the do-nothing quadrant ("the combination would be a no-op: nothing would change"). The conflict fires whether either flag is spelled on the command line or sourced from its env var | | `rollback` | optional variadic positional `targets` (PURL \| UUID \| path glob); `--one-off`; `--preserve-state` (v5.0) | `SOCKET_ONE_OFF`, `SOCKET_PRESERVE_STATE` | Rollback scope. Multiple targets union. A token becomes a path glob ONLY when it is path-SHAPED — contains a separator (`/` or `\`) or a glob metacharacter (`*?[`), or starts with `./`, or is absolute; a `pkg:` prefix is a PURL and every other bare word keeps identifier (PURL/UUID) semantics, so a mistyped identifier or truncated UUID stays a safe exit-1 "No patch found matching identifier: X" (with a hint suggesting `./X` or `X/**` for directory targeting) instead of silently becoming a path scope. An unparseable glob is a usage error (exit 2) | | `vex` | `--output` / `-O`, `--product`, `--no-verify`, `--doc-id`, `--compact` | `SOCKET_VEX_OUTPUT`, `SOCKET_VEX_PRODUCT`, `SOCKET_VEX_NO_VERIFY`, `SOCKET_VEX_DOC_ID`, `SOCKET_VEX_COMPACT` | OpenVEX 0.2.0 document generation; see "vex output channels" below | @@ -106,17 +109,21 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Takeover reconciliation (npm family, bun and vlt included)**: vendoring over a hosted-redirected purl (`vendor`, `scan --mode vendored`, `get --mode vendored`) first REVERTS that purl's hosted lockfile edits to their pre-redirect registry values through the per-purl redirect revert, drops the purl's record + package edits from `redirect-state.json`, and then vendors — so the vendor ledger records the PRISTINE registry fragment as its wiring `original` and `vendor --revert` lands back on registry state, never on an expiring hosted URL. The run that takes over records a `vendor_takeover_reverted_redirect` advisory event (`skipped` action beside the purl's genuine outcome; the human path prints `Warning (vendor_takeover_reverted_redirect): …`). `--dry-run` PROBES the same revert against an in-memory ledger clone instead of promising it: a clean probe reports `vendor_would_revert_redirect`, and a drifted lock or an undecidable ledger edit surfaces in the preview with the wet run's `redirect_revert_failed` code and detail (for bun, whose hosted rewrite replaces the entry's `name@version` spec, the preview first runs the Bun vendored preflight described below and then stops at the advisory instead of reading the still-hosted lock — a lock the vendored backend would refuse is previewed as the wet run's `failed `, never as `vendor_would_revert_redirect`). A purl whose hosted edits cannot be cleanly reverted fails `redirect_revert_failed` (exit 1 / `partial_failure`, nothing vendored for it, the hosted wiring left in place, the remedy in the detail). **bun** participates like every other npm-family flavor: binary `redirect_bun_lockb_package` snapshots are claimed by their recorded package identity and restore individual binary resolutions; its text `redirect_bun_lock_package` edits are claimed by the recorded line's spec — the registry spec `@`, or a hosted URL whose tarball leaf is `-.tgz` — so a sibling version's or an aliased sibling's edit is neither claimed nor a refusal, and only an edit that mentions the package without being a bun packages-entry line refuses (remedy: an unscoped `socket-patch rollback`, whose whole-ledger replay unwinds bun.lock hosted edits; never hand-edit the ledger). The same claim rule serves scoped `rollback ` / `remove ` of one of several hosted bun records (see "Hosted unwind coverage"). Hosted → vendored and vendored → hosted (`redirect_takeover_reverted_vendored` in `redirect.warnings[]`) both work in place on bun locks the target mode accepts. **Bun vendored preflight before the takeover**: `vendor` — like `scan` / `get --mode vendored`, whose pre-download preflight runs earlier — checks `bun.lock` / `bun.lockb` with the shared Bun vendored preflight BEFORE the per-purl hosted revert, so a hosted-redirected purl on a lock the vendored backend refuses (a pre-version-2 `workspace:` lock → `vendor_bun_workspace_unsupported`; a malformed or unsupported binary lock → `vendor_bun_lockb_invalid`; an unsupported text-lock version → its code) is reported `failed ` with the hosted wiring, the redirect ledger and active Bun lock byte-untouched (exit 1 / `partial_failure`): the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed` code (exit-code parity with the wet run, nothing written) instead of promising `vendor_would_revert_redirect`. Pinned by `tests/in_process_vendor_bun_takeover.rs` and, against real Bun, `tests/mode_migration_bun.rs`. **golang** takes over the same way: the per-purl revert drops the module's hosted `replace`, removes the socket module's go.sum lines, puts the pruned upstream go.sum lines back in go's sort order, and drops the ledger record, so the vendored `replace` is recorded over pristine go.mod/go.sum (a go.mod whose replace for the module is no longer the recorded one refuses `redirect_revert_failed`). The separate run-level `vendor_supersedes_redirect` warning covers the reconcile-only case — a live lock that already proves vendored won over a stale hosted ledger record (the vendor wiring then holds the hosted-spliced fragment as `original`) — and fires exactly once, on the run that drops the stale records. Which way the live lock points is decided by the same lockfile discovery and ledger-liveness rules `vex` gates attestations on (see "Manifest-less VEX (lockfile discovery)"), for this warning, its `redirect_supersedes_vendored` twin and `hosted_wiring_retained` alike. -`scan --apply` opts JSON callers into the full discover → select → apply pipeline. Without it, `scan --json` stays read-only (discovery + the `updates` array + the `redirectState` state block below). No effect outside `--json` mode. The non-JSON path prompts the user interactively in a TTY; when stdin is NOT a TTY (CI, a pipe), `--yes` is absent, and no intent flag (`--mode`, `--apply`, `--sync`, `--vendor`, `--redirect`, `--prune`) is given, a human-mode `scan` is **report-only** (v5.0): it prints the discovery report and the existing "To apply a single patch, run: …" hint, downloads nothing, writes nothing (no `.socket/`), and exits 0. Any intent flag, `--yes`, or a TTY keeps the previous behavior (prompt in a TTY, auto-proceed otherwise). Only `scan` gained this pre-check — `rollback`/`remove`/`get`'s non-TTY auto-accept is unchanged. +### Scan modes (v5.0) -**Hosted-state visibility (`redirectState`, additive/MINOR).** Every non-hosted-mode, non-vendored-mode `scan --json` SUCCESS envelope (report-only, `--mode agent`/`--apply`/`--sync`, and the zero-discovery envelope) carries an additive top-level `redirectState` object whenever the hosted redirect ledger (`.socket/vendor/redirect-state.json`) holds ≥ 1 `records` entry: `{ mode, ledger, records: [{purl, ledgerKey, uuid}], wiringLive: [purl] }`. It is a descriptive STATE block, not a warning — a hosted-wired project's report-only scan used to be byte-identical to a never-touched project's. `mode` is the constant `"hosted"` (the mode's documented name, whatever opaque `mode` string the ledger itself carries — pre-rename ledgers say `"redirect"`) and `ledger` the ledger's repo-relative path. `records` lists every ledger record (sorted by ledger key): each entry's `purl` is CANONICALIZED (qualifiers stripped, percent-decoded — e.g. `pkg:npm/@scope/pkg@1.0.0`, `pkg:gem/nokogiri@1.13.3`) to the same spelling `wiringLive` carries, so the records↔proof join is a plain string compare, and `ledgerKey` preserves the ledger's verbatim key (percent-encoded scoped names, `?platform=` qualifiers) for consumers addressing the ledger itself. `wiringLive` is the subset of this run's *counted* purls (post-`--ecosystems`-filter) whose hosted lockfile wiring the LIVE lock still proves — the same proof, computed once per run, that feeds `hosted_wiring_retained`, and the same liveness rule `vex` applies to a redirect-ledger record (see "Manifest-less VEX (lockfile discovery)"). Consumers must treat the split as exactly that: records are the ledger's word, `wiringLive` the live lock's proof — a record with no proof means the wiring was unwound, the lock is unreadable, or the purl was not crawled/queried this run (an `--ecosystems` filter, a zero discovery), never "still live". The key is omitted when the ledger is absent or its `records` are empty (an edits-only ledger asserts no patches), and error envelopes (the `--offline` refusal, all-batches-failed) are deliberately minimal and never carry it. A malformed ledger degrades to "nothing to consult" (no block) with a stderr warning, muted by `--silent`. Hosted-mode runs carry the `redirect` sub-object instead (the run's own result; the ledger is re-persisted mid-run), and vendored-mode runs carry the takeover warnings (their reconciliation may retire records mid-run) — neither duplicates a pre-run snapshot that could go stale. +**Mode resolution (`resolve_mode_flags`, MAJOR in v5.0).** `--mode`, or one of its legacy boolean spellings (`--redirect`, `--vendor`, `--apply`/`--sync`), picks the mode. With none of them, `scan` runs **hosted** mode — JSON and human alike; the result nests under the JSON `redirect` sub-object (see the hosted paragraph below). The one exception: a `--prune` or `--global`/`--global-prefix` scan with no mode has no project lockfile to rewire, so it is **report-only** — discovery, the table, the `updates` array and the `redirectState` block below, plus the `--prune` GC — and, in human mode, ends with the hint `To apply these patches in place, run:` / ` socket-patch scan --mode agent [PATHS]` / ` socket-patch get `. An explicit `--mode hosted` (or `--redirect`) with `--global`/`--global-prefix` is a usage error (exit 2: global installs have no project lockfile to redirect). -**Agent-flow run-level warnings (additive).** An agent-mode apply (`--mode agent` / `--apply` / `--sync`, `--json`) may add a top-level `warnings[]` array of `{code, detail}` entries to the scan envelope (absent when none fired; each is also mirrored to stderr unless `--silent`). They surface cross-mode state the apply cannot change — never a status or exit-code change (hosted refusals set the precedent: exit 0 + warning). Codes (stable; new codes are additive/MINOR): `vendored_ownership_retained` — vendor-owned package(s) were skipped before download (the per-patch `skipped`/`vendored` records in `apply.patches[]` are unchanged); the detail names the purls and the migration path (`remove `, or `vendor --revert` which unwinds every vendored package, then re-run). `hosted_wiring_retained` — the hosted redirect ledger records scanned package(s) whose hosted lockfile wiring the live lock still proves (the agent run does not unwind hosted wiring — as of v5.0 that is `socket-patch rollback`'s job, or `remove ` per package); the detail names the purls and the options (stay `--mode hosted`, or migrate via `scan --mode vendored`) and never advises hand-deleting the ledger. The warning keys on ledger *records* still live at scan time — a flow that pre-reverted the redirect (retiring the records) retires the warning with them, even while the append-only `edits` (revert originals) remain. The interactive path prints the same `hosted_wiring_retained` text to stderr after an apply; the vendored counterpart is already covered by its per-package `[skip] … (vendored …)` lines. `ownership_not_restored` (v5.0; `apply` and `rollback` `warnings[]` alike) — a file WAS patched (or restored) but its ownership could not be put back to the original uid/gid (the mode is still restored last); the detail is `: : patched, but ownership could not be restored to uid N gid M: ` and the human line `Warning (ownership_not_restored): ` (stderr, muted by `--silent`); never a status or exit change. +**scan never prompts, in any mode** (v5.0): no confirm, no free-tier patch menu (it always takes the top-ranked downloadable patch; see "Which patch gets selected"), and no `Non-interactive mode detected` note. `--yes` does not change a scan. `get`, `rollback`, `remove`, `setup` and `--update` keep their prompts. -`scan --prune` opts into garbage collection. When set, `scan` removes manifest entries for packages no longer present in the crawl, then deletes orphan blob, diff, and package-archive files from `.socket/`. Off by default (v3.0) so a temporary uninstall doesn't silently destroy manifest state. Only entries whose ecosystem this run actually crawled are eligible: a `pkg:/` with no crawler in this build (a newer CLI's ecosystem in the committed manifest) and the runtime-gated maven/nuget crawlers with their gate off are exempt — the crawl never looked for them, so their absence is not evidence of removal (same fail-safe as the `--ecosystems` filter, which narrows the query but never the prune's installed set). The pass also reconciles vendored state (runs FIRST, under ONE apply-lock acquisition shared with the manifest prune — lock contention skips the whole pass without failing the scan; `--lock-timeout` is honored and a lock I/O error is reported rather than swallowed; the existence gate — a manifest file OR a vendor ledger file, both cheap stats; an emptied ledger is deleted on save, so its presence is its content proxy — runs BEFORE the lock, so a bare project never gets a `.socket/`; in the vendored scan arms the pass runs AFTER the vendor step): (a) ledger entries still tracked by a manifest record (manifest-mode entries written by standalone `vendor`) whose patch is gone from the manifest are reverted — `detached` entries (every `scan`/`get --mode vendored` entry, v5.0) have no manifest record to lose and are exempt from this leg; (b) EVERY ledger entry whose dependency is no longer in the lockfile graph is reverted and any manifest entry it still had dropped (v5.0: the check is about the lockfile, not the manifest, so embedded-record entries are no longer exempt; a missing or undeterminable lockfile keeps the entry, fail-safe); and (c) orphan `.socket/vendor//` dirs with no ledger entry are swept. The prune never deletes a zero-patch `.socket/manifest.json` (its `{"patches": {}}` + `setup` block stay). The JSON `gc` sub-object gains `revertedVendoredEntries` + `keptVendoredEntries` + `failedVendoredEntries` + `removedVendorOrphanDirs` (wet) / `revertableVendoredEntries` + `vendorOrphanDirs` (preview), plus two ADDITIVE wet-only keys: `skipped: {code, message}` — present exactly when the pass was skipped at the lock (`lock_held` | `lock_io`; every count is then zero) — and `warnings: [{code, detail}]` — `vendor_state_write_failed` / `manifest_write_failed` (entries were reverted but the ledger or manifest rewrite failed) and `cleanup_failed` (an orphan sweep failed mid-way). Human mode prints `GC: skipped (): .`, one `GC: .` line per warning, and `GC: failed to revert N vendored entries: …` (singular for one) for `failedVendoredEntries`. `keptVendoredEntries` lists drift-kept entries the revert deliberately preserved (`vendor_artifact_kept` — undo the drift and re-run `vendor --revert` to finish); the preview cannot see drift (backends return before the wiring replay on dry runs), so `revertableVendoredEntries` may over-promise what a wet run will actually reclaim. +**Hosted-state visibility (`redirectState`, additive/MINOR).** Every non-hosted-mode, non-vendored-mode `scan --json` SUCCESS envelope (report-only, `--mode agent`/`--apply`/`--sync`, and the zero-discovery envelope) carries an additive top-level `redirectState` object whenever the hosted redirect ledger (`.socket/vendor/redirect-state.json`) holds ≥ 1 `records` entry: `{ mode, ledger, records: [{purl, ledgerKey, uuid}], wiringLive: [purl] }`. It is a descriptive STATE block, not a warning. `mode` is the constant `"hosted"` (the mode's documented name, whatever opaque `mode` string the ledger itself carries — pre-rename ledgers say `"redirect"`) and `ledger` the ledger's repo-relative path. `records` lists every ledger record (sorted by ledger key): each entry's `purl` is CANONICALIZED (qualifiers stripped, percent-decoded — e.g. `pkg:npm/@scope/pkg@1.0.0`, `pkg:gem/nokogiri@1.13.3`) to the same spelling `wiringLive` carries, so the records↔proof join is a plain string compare, and `ledgerKey` preserves the ledger's verbatim key (percent-encoded scoped names, `?platform=` qualifiers) for consumers addressing the ledger itself. `wiringLive` is the subset of this run's *counted* purls (post-`--ecosystems`-filter) whose hosted lockfile wiring the LIVE lock still proves — the same proof, computed once per run, that feeds `hosted_wiring_retained`, and the same liveness rule `vex` applies to a redirect-ledger record (see "Manifest-less VEX (lockfile discovery)"). Consumers must treat the split as exactly that: records are the ledger's word, `wiringLive` the live lock's proof — a record with no proof means the wiring was unwound, the lock is unreadable, or the purl was not crawled/queried this run (an `--ecosystems` filter, a zero discovery), never "still live". The key is omitted when the ledger is absent or its `records` are empty (an edits-only ledger asserts no patches), and error envelopes (the `--offline` refusal, all-batches-failed) are deliberately minimal and never carry it. A malformed ledger degrades to "nothing to consult" (no block) with a stderr warning, muted by `--silent`. Hosted-mode runs carry the `redirect` sub-object instead (the run's own result; the ledger is re-persisted mid-run), and vendored-mode runs carry the takeover warnings (their reconciliation may retire records mid-run) — neither duplicates a pre-run snapshot that could go stale. + +**Agent-flow run-level warnings (additive).** An agent-mode apply (`--mode agent` / `--apply` / `--sync`, `--json`) may add a top-level `warnings[]` array of `{code, detail}` entries to the scan envelope (absent when none fired; each is also mirrored to stderr unless `--silent`). They surface cross-mode state the apply cannot change — never a status or exit-code change (hosted refusals set the precedent: exit 0 + warning). Codes (stable; new codes are additive/MINOR): `vendored_ownership_retained` — vendor-owned package(s) were skipped before download (the per-patch `skipped`/`vendored` records in `apply.patches[]` are unchanged); the detail names the purls and the migration path (`remove `, or `vendor --revert` which unwinds every vendored package, then re-run). `hosted_wiring_retained` — the hosted redirect ledger records scanned package(s) whose hosted lockfile wiring the live lock still proves (the agent run does not unwind hosted wiring — as of v5.0 that is `socket-patch rollback`'s job, or `remove ` per package); the detail names the purls and the options (stay `--mode hosted`, or migrate via `scan --mode vendored`) and never advises hand-deleting the ledger. The warning keys on ledger *records* still live at scan time — a flow that pre-reverted the redirect (retiring the records) retires the warning with them, even while the append-only `edits` (revert originals) remain. The human path prints the same `hosted_wiring_retained` text to stderr after an apply; the vendored counterpart is already covered by its per-package `[skip] … (vendored …)` lines. `ownership_not_restored` (v5.0; `apply` and `rollback` `warnings[]` alike) — a file WAS patched (or restored) but its ownership could not be put back to the original uid/gid (the mode is still restored last); the detail is `: : patched, but ownership could not be restored to uid N gid M: ` and the human line `Warning (ownership_not_restored): ` (stderr, muted by `--silent`); never a status or exit change. + +`scan --prune` opts into garbage collection. When set, `scan` removes manifest entries for packages no longer present in the crawl, then deletes orphan blob, diff, and package-archive files from `.socket/`. Off by default (v3.0) so a temporary uninstall doesn't silently destroy manifest state. Only entries whose ecosystem this run actually crawled are eligible: a `pkg:/` with no crawler in this build (a newer CLI's ecosystem in the committed manifest) is exempt — the crawl never looked for them, so their absence is not evidence of removal (same fail-safe as the `--ecosystems` filter, which narrows the query but never the prune's installed set). The pass also reconciles vendored state (runs FIRST, under ONE apply-lock acquisition shared with the manifest prune — lock contention skips the whole pass without failing the scan; `--lock-timeout` is honored and a lock I/O error is reported rather than swallowed; the existence gate — a manifest file OR a vendor ledger file, both cheap stats; an emptied ledger is deleted on save, so its presence is its content proxy — runs BEFORE the lock, so a bare project never gets a `.socket/`; in the vendored scan arms the pass runs AFTER the vendor step): (a) ledger entries still tracked by a manifest record (manifest-mode entries written by standalone `vendor`) whose patch is gone from the manifest are reverted — `detached` entries (every `scan`/`get --mode vendored` entry, v5.0) have no manifest record to lose and are exempt from this leg; (b) EVERY ledger entry whose dependency is no longer in the lockfile graph is reverted and any manifest entry it still had dropped (v5.0: the check is about the lockfile, not the manifest, so embedded-record entries are no longer exempt; a missing or undeterminable lockfile keeps the entry, fail-safe); and (c) orphan `.socket/vendor//` dirs with no ledger entry are swept. The prune never deletes a zero-patch `.socket/manifest.json` (its `{"patches": {}}` + `setup` block stay). The JSON `gc` sub-object gains `revertedVendoredEntries` + `keptVendoredEntries` + `failedVendoredEntries` + `removedVendorOrphanDirs` (wet) / `revertableVendoredEntries` + `vendorOrphanDirs` (preview), plus two ADDITIVE wet-only keys: `skipped: {code, message}` — present exactly when the pass was skipped at the lock (`lock_held` | `lock_io`; every count is then zero) — and `warnings: [{code, detail}]` — `vendor_state_write_failed` / `manifest_write_failed` (entries were reverted but the ledger or manifest rewrite failed) and `cleanup_failed` (an orphan sweep failed mid-way). Human mode prints `GC: skipped (): .`, one `GC: .` line per warning, and `GC: failed to revert N vendored entries: …` (singular for one) for `failedVendoredEntries`. `keptVendoredEntries` lists drift-kept entries the revert deliberately preserved (`vendor_artifact_kept` — undo the drift and re-run `vendor --revert` to finish); the preview cannot see drift (backends return before the wiring replay on dry runs), so `revertableVendoredEntries` may over-promise what a wet run will actually reclaim. `scan` queries the patch API in `--batch-size` chunks. Authenticated runs POST `/v0/orgs/{slug}/patches/batch`; token-less runs POST `{proxy}/patch/batch` on the public proxy and degrade to per-package `GET /patch/by-package/:purl` requests in two cases: the deployed proxy predates the batch endpoint (legacy proxies answer the POST with their `400 "Unsupported endpoint"` catch-all), or the all-or-nothing batch validation rejects the chunk (e.g. a crawled PURL type the server doesn't recognize, such as `pkg:jsr/…` — the per-package path tolerates those individually, preserving the pre-batch scan semantics). Rate limits and over-capacity 503s surface instead of silently degrading. -**Throttling: bounded retry, then a reported failure.** Every patch-API JSON call (the batch query, the per-package patch lists, patch views and VEX record fetches, hosted package references) retries an HTTP `429` or `503` answer up to 3 times (`SOCKET_API_MAX_RETRIES=`, `0`-`10`; `0` = no retry). The wait honors `Retry-After` (delta-seconds or HTTP-date); a `Retry-After` over 30 s is not waited out — the answer is final at once — and one under the jittered first backoff step (`0`, a past date) waits that step instead. Without one it backs off 0.5 s / 1 s / 2 s (each step up to 8 s, with jitter in its upper half). All retries in one run share a 60 s wall-clock window that opens with the run's first retry: a retry whose wait would end after it closes is refused and the answer is final. Parallel requests wait in parallel, so each still gets its retries while the run adds at most about 60 s. Nothing else is retried (401/403 still drive the proxy fallback on the first answer; the public proxy's permanent `503 "Patch API is not configured"` is never retried on any path — the batch query still degrades to the per-package path at once, and a per-package lookup or patch view answering it is the same non-throttle failure it always was, so the legacy per-package path still skips that package), and a retried answer folds exactly where the first attempt's would have, so output is identical to an unthrottled run's. A request still throttled after that is a failure in the channel its siblings use: a failed batch is the human `Warning: API batch of failed: ` line and, under `--json`, a run-level `warnings[]` entry `{code: "api_batch_failed", detail: "API batch of failed: "}` (additive; `status` stays `success`, exit 0 — the other batches' packages are reported); a failed per-package patch-list query in the agent / hosted / vendored flows is the human `Warning: could not fetch details for : ` line and, under `--json`, `{code: "patch_details_failed", detail: "could not fetch details for : "}`. When every batch (or every patch-list query) fails, the existing all-failed error envelope and exit 1 apply. The error names the exhausted retry: `Rate limit exceeded (HTTP 429, gave up after 3 retries). Please try again later.` / `API request failed with status 503: (gave up after 3 retries)` (or `(Retry-After s exceeds the 30 s retry cap)` / `(the run's 60 s retry window has closed)`); with retries off it is the pre-retry text. On the token-less legacy per-package proxy path (a proxy without `POST /patch/batch`), a package still throttled (429 / over-capacity 503) after its retries fails its whole batch query, so every package in that batch goes unchecked and is reported through the batch-failure channel above (an unresolvable PURL, or a "not configured" 503, is still skipped individually). Before this, a throttled batch vanished from a `--json` envelope without a trace. Pinned by `tests/scan_api_retry_e2e.rs` and the core crate's `tests/api_retry_e2e.rs`. +**Throttling: bounded retry, then a reported failure.** Every patch-API JSON call (the batch query, the per-package patch lists, patch views and VEX record fetches, hosted package references) retries an HTTP `429` or `503` answer up to 3 times (`SOCKET_API_MAX_RETRIES=`, `0`-`10`; `0` = no retry). The wait honors `Retry-After` (delta-seconds or HTTP-date); a `Retry-After` over 30 s is not waited out — the answer is final at once — and one under the jittered first backoff step (`0`, a past date) waits that step instead. Without one it backs off 0.5 s / 1 s / 2 s (each step up to 8 s, with jitter in its upper half). All retries in one run share a 60 s wall-clock window that opens with the run's first retry: a retry whose wait would end after it closes is refused and the answer is final. Parallel requests wait in parallel, so each still gets its retries while the run adds at most about 60 s. Nothing else is retried (401/403 still drive the proxy fallback on the first answer; the public proxy's permanent `503 "Patch API is not configured"` is never retried on any path — the batch query still degrades to the per-package path at once, and a per-package lookup or patch view answering it is the same non-throttle failure it always was, so the legacy per-package path still skips that package), and a retried answer folds exactly where the first attempt's would have, so output is identical to an unthrottled run's. A request still throttled after that is a failure in the channel its siblings use: a failed batch is the human `Warning: API batch of failed: ` line and, under `--json`, a run-level `warnings[]` entry `{code: "api_batch_failed", detail: "API batch of failed: "}` (additive; `status` stays `success`, exit 0 — the other batches' packages are reported); a failed per-package patch-list query in the agent / hosted / vendored flows is the human `Warning: could not fetch details for : ` line and, under `--json`, `{code: "patch_details_failed", detail: "could not fetch details for : "}`. When every batch (or every patch-list query) fails, the existing all-failed error envelope and exit 1 apply. The error names the exhausted retry: `Rate limit exceeded (HTTP 429, gave up after 3 retries). Please try again later.` / `API request failed with status 503: (gave up after 3 retries)` (or `(Retry-After s exceeds the 30 s retry cap)` / `(the run's 60 s retry window has closed)`); with retries off it is the pre-retry text. On the token-less legacy per-package proxy path (a proxy without `POST /patch/batch`), a package still throttled (429 / over-capacity 503) after its retries fails its whole batch query, so every package in that batch goes unchecked and is reported through the batch-failure channel above (an unresolvable PURL, or a "not configured" 503, is still skipped individually). Pinned by `tests/scan_api_retry_e2e.rs` and the core crate's `tests/api_retry_e2e.rs`. **Lockfile supplement (v3.4)**: `scan` discovery is no longer limited to installed trees. The project's lockfiles (`package-lock.json`/`npm-shrinkwrap.json`, `pnpm-lock.yaml` v9, `yarn.lock` classic + berry, `bun.lock`, `vlt-lock.json` (registry nodes, Socket-hosted pins included; vendored `file` nodes are left to the vendor ledger), `Cargo.lock`, `go.sum`, `composer.lock`, `Gemfile.lock`, `uv.lock`/`poetry.lock`/pinned `requirements.txt`) are inventoried and dependencies with NO installed copy join discovery — counts, the API lookup, the table (flagged ` [NOT INSTALLED]`, plus a stderr note), and the prune "scanned" set (a wiped node_modules no longer prunes lockfile-listed entries). JSON gains a top-level `lockfileOnlyPackages` count and an additive `notInstalled: true` on matching `packages[]` entries. `--apply` partitions lockfile-only patches out BEFORE download (calm `skipped`/`package_not_installed` records — never an error exit, never a manifest write); `--vendor` passes them through to the vendor engine's auto-fetch. Vendored-ledger entries likewise stay discoverable on a fresh clone (the committed artifact is the dependency). Global scans (`--global`) get no supplement. **Rush monorepos** (no root lockfile, `rush.json` present): the npm-lock inventory falls back to the Rush source-of-truth locks — `common/config/rush/pnpm-lock.yaml` plus every `common/config/subspaces/*/pnpm-lock.yaml` (`read_dir`-sorted, repo-relative paths preserved) — so a Rush repo's dependencies still join discovery. **Plug'n'Play layouts are an explicit refusal, not an empty inventory**: a `.pnp.*` loader means the npm packages are structurally unreachable in EVERY mode (under yarn PnP the installed-tree crawl is empty too — no `node_modules/`), so `scan` surfaces an additive top-level `warnings[]` array (`{code, detail}` objects, omitted when empty) carrying `yarn_pnp_unsupported` (same code as apply's refusal; remedy `yarn patch `) or `pnpm_pnp_unsupported` (pnpm's `node-linker=pnp` twin; pnpm remedies), plus a stderr `Warning (): …` line on the human path. Exit code and `status` are deliberately unchanged (exit 0 / `success` — the same posture as hosted refusals, which exit 0 with `redirected: 0`); the warning is the machine-readable signal that nothing was checked. Pinned by `tests/e2e_safety_yarn_pnp.rs`. @@ -126,17 +133,20 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Vendored group commit (v5.0)**: `vendor`, `scan --mode vendored` and `get --mode vendored` capture every lockfile / manifest / config edit and every ledger save of the run in memory (reads inside the run see them) and commit them ONCE after the per-package loop — including the packages that succeeded in a run where others failed, so a completed run leaves the same files per-package commits would. Captured: every file under the project root outside `.socket/`, plus `.socket/vendor/state.json` and `.socket/vendor/redirect-state.json`; artifacts are written directly (see the durability note). A multi-file commit goes through a roll-forward journal, `.socket/vendor/.commit-journal.json` (the new bytes of every changed file, plus the bytes each replaces and their sha256; deleted once the commit completes). **Crash semantics**: before the journal is durable, nothing is committed — the lockfiles and ledgers are the pre-run ones and the run's artifacts are unreferenced orphans; after it, the next command that takes the apply lock replays the journal before reading anything (files already at their new bytes are left alone), so a locked command never observes a half-committed run. A journal that matches neither side of some file (edited by hand since the crash) is renamed to `.socket/vendor/.commit-journal.set-aside-.json` (keeping every file's pre-commit bytes) and stderr says what was done (`Warning: an interrupted vendored run's commit could not be finished as written: …`): the edited files are never written over; when they all still carry the commit's own lines the rest of the commit is finished around them, when none of them does the files the crash had already replaced are put back to their pre-commit bytes, and otherwise nothing is applied. A journal that is unreadable, names a path outside the lockfiles and ledgers, or would write through a symbolic link is set aside with nothing applied. A replay that fails on I/O keeps the journal and fails the lock acquire (`lock_io`, naming the journal). Read-only commands that take no lock (`vex`, `list`) may observe the interrupted state until then. A re-vendor under a newer uuid removes the replaced uuid's dir only after the commit (its `vendor_stale_artifact_removed` event follows the run's per-package events), and a golang takeover removes the `.socket/go-patches/` copy only after the commit that repoints `go.mod`. A commit write failure is the top-level error `vendor_commit_failed` (exit 1; the pre-run lockfiles and ledger stay — unless putting back the files already replaced failed too, in which case the journal is kept and the next locked command finishes the commit). `repair`, `vendor --revert` and `rollback` still save per entry. -`scan --sync` is sugar for `--apply --prune` — the canonical single-flag bot invocation. `scan --json --sync --yes` discovers, applies, and reconciles state in one pass. +`scan --sync` is sugar for `--mode agent --prune` — the canonical single-flag agent-mode bot invocation. `scan --json --sync` discovers, applies, and reconciles state in one pass. + +**`scan --ecosystems` scopes the crawl (v5.0)**: without `--prune`/`--sync`, a `scan` given `--ecosystems`/`-e` runs only the named ecosystems' crawlers — everything the run counts, queries and shows (`scannedPackages`, the batch query, `packages[]`, the table, `updates[]`, `wiringLive`, the `gem_bundle_config_path_ignored` warning) was already narrowed to them, so the skipped crawls could only be filtered away. The one visible difference: `lockfileOnlyPackages` (and the human "not yet installed" note) counts only the selected ecosystems' lockfile-only entries (a skipped crawl cannot vouch for another ecosystem's uninstalled lockfile entries). A GC run (`--prune`, or `--sync`, which implies it — in every mode, hosted included) still crawls every ecosystem, because the prune judges each manifest entry against the FULL installed set (see `scan --prune` above); its output, `lockfileOnlyPackages` included, is unchanged. Without `--ecosystems` nothing changes. Pinned by `tests/scan_ecosystems_scope_e2e.rs`. -**`scan --ecosystems` scopes the crawl (v5.0)**: without `--prune`/`--sync`, a `scan` given `--ecosystems`/`-e` runs only the named ecosystems' crawlers — everything the run counts, queries and shows (`scannedPackages`, the batch query, `packages[]`, the table, `updates[]`, `wiringLive`, the `gem_bundle_config_path_ignored` warning) was already narrowed to them, so the skipped crawls could only be filtered away. The one visible difference: `lockfileOnlyPackages` (and the human "not yet installed" note) counts only the selected ecosystems' lockfile-only entries — previously it also counted other ecosystems' uninstalled lockfile entries, which a skipped crawl can no longer vouch for. A GC run (`--prune`, or `--sync`, which implies it — in every mode, hosted included) still crawls every ecosystem, because the prune judges each manifest entry against the FULL installed set (see `scan --prune` above); its output, `lockfileOnlyPackages` included, is unchanged. Without `--ecosystems` nothing changes. Pinned by `tests/scan_ecosystems_scope_e2e.rs`. +**Path-scoped scans (`scan [PATHS]...`, v5.0)**: what a PATH means depends on the mode. -**Path-scoped scans (`scan [PATHS]...`, v5.0)**: optional variadic positional path globs scope DISCOVERY at the **purl level** — a package is in scope iff ANY of its crawled installed copies sits under a matching path, and a selected package is then handled with ALL its copies (scoping selects which packages are considered, never which copies). Glob semantics (shared with `rollback`'s path targets, `src/path_scope.rs`): Unix-shell globs with `require_literal_separator` — `*`/`?` never cross a `/`, `**` spans directories; a pattern matching any **ancestor** directory of the copy path also matches, so a bare `scan packages/foo` scopes the whole subtree without `/**`; relative patterns match against the copy path relativized to `--cwd`, absolute patterns against the absolute path (the ONLY way to reach paths outside the project tree, e.g. `--global` stores — a relative pattern never matches outside `--cwd`); leading `./` and trailing `/` are normalized away, matching is purely textual (no filesystem access or symlink resolution), case-sensitive except on Windows (whose filesystems are not); an unparseable or empty pattern is a usage error (exit 2). **The prune universe is never narrowed**: the path filter is applied strictly AFTER the `scanned_purls` capture (and after `--ecosystems`), so `scan PATHS --prune` prunes exactly what an unscoped `scan --prune` would — a scoped scan can never treat an out-of-scope package as uninstalled (the same fail-safe as the `--ecosystems` filter). Lockfile-only and vendor-ledger supplement records have no installed path and are EXCLUDED from a path-scoped scan, surfaced as one run-level `path_scope_excluded_supplements` warning carrying the count. A scope matching nothing is a normal empty scan — exit 0, zero packages, **no GC** (the zero-package early return fires before any GC). `PATHS` with `--mode hosted` or `--mode vendored` is a usage error (exit 2, `resolve_mode_flags`: "path targeting … applies to agent-mode and read-only scans" — their lockfile rewiring is whole-project by construction); `PATHS` with `--apply`/`--sync`/`--prune`/`--global` is fine. Every scan JSON shape (success, zero-package, and error alike) gains an additive always-present `paths` key echoing the patterns verbatim (empty array when unscoped). One-sentence duality rule: **a target that selects nothing is an error on `rollback` (exit 1) and an empty scan on `scan` (exit 0)**. +* **Hosted and vendored mode (bare `scan` included) — project directories** (`run_project_dirs`). Each PATH is a directory, or a glob (`*?[`) matching directories, relative to `--cwd`; the set is sorted and deduplicated, and each directory is scanned on its own exactly as if it were `--cwd` (its own lockfiles, ledgers and `.socket/`). With more than one directory, each run is headed `== ==` on stdout (unless `--silent`), and the exit code is the worst of the runs. Usage errors (exit 2, stderr only, before any scan): a PATH that is not a directory (`` `X` is not a directory``), a glob matching no directory (`` `X` matches no directory``), an invalid glob, and `--json` with more than one directory (`--json takes one project directory (N given); run one scan per directory`), so stdout stays one document. +* **Agent mode (and a mode-less `--prune`/`--global` report) — installed-path globs** scoping DISCOVERY at the **purl level**: a package is in scope iff ANY of its crawled installed copies sits under a matching path, and a selected package is then handled with ALL its copies (scoping selects which packages are considered, never which copies). Glob semantics (shared with `rollback`'s path targets, `src/path_scope.rs`): Unix-shell globs with `require_literal_separator` — `*`/`?` never cross a `/`, `**` spans directories; a pattern matching any **ancestor** directory of the copy path also matches, so a bare `scan packages/foo` scopes the whole subtree without `/**`; relative patterns match against the copy path relativized to `--cwd`, absolute patterns against the absolute path (the ONLY way to reach paths outside the project tree, e.g. `--global` stores — a relative pattern never matches outside `--cwd`); leading `./` and trailing `/` are normalized away, matching is purely textual (no filesystem access or symlink resolution), case-sensitive except on Windows (whose filesystems are not); an unparseable or empty pattern is a usage error (exit 2). **The prune universe is never narrowed**: the path filter is applied strictly AFTER the `scanned_purls` capture (and after `--ecosystems`), so `scan PATHS --prune` prunes exactly what an unscoped `scan --prune` would — a scoped scan can never treat an out-of-scope package as uninstalled (the same fail-safe as the `--ecosystems` filter). Lockfile-only and vendor-ledger supplement records have no installed path and are EXCLUDED from a path-scoped scan, surfaced as one run-level `path_scope_excluded_supplements` warning carrying the count. A scope matching nothing is a normal empty scan — exit 0, zero packages, **no GC** (the zero-package early return fires before any GC). `PATHS` combine with `--apply`/`--sync`/`--prune`/`--global`. Every scan JSON shape (success, zero-package, and error alike) carries an always-present `paths` key echoing the patterns verbatim (empty array when unscoped; a hosted/vendored per-directory run is unscoped, so it is `[]`). One-sentence duality rule: **a target that selects nothing is an error on `rollback` (exit 1) and an empty scan on an agent-mode `scan` (exit 0)**. `scan --vendor` swaps the in-place apply for the vendor pipeline: discover → download the selected patch records **into memory** (no manifest write) → vendor every selected dependency via the same engine as the `vendor` command (under the same lock). Vendored mode is **manifest-free (v5.0)**: `.socket/manifest.json` is never written or read by a vendored run; each ledger entry carries `detached: true` plus an embedded copy of the patch record (`record`) as its verification source, and the run's footprint is `.socket/vendor/**` only. The vendor step's scope is what discovery selected — the former "whole manifest is vendored" re-vendor on an empty discovery is retired (`repair` verifies and rebuilds committed vendored state; `scan --prune` reconciles ledger entries whose dependency left the lockfile). A package the ledger holds at an older patch uuid is still **re-vendored automatically** when discovery selects the newer patch (its old uuid dir is removed — `vendor_stale_artifact_removed`); same-uuid re-runs reuse the embedded record, skip the patch-view fetch, and are `already_vendored` skips. **Legacy manifest-mode entries**: when a vendored run vendors a purl that also has a `.socket/manifest.json` record (a project vendored by a pre-5.0 binary, or by standalone `vendor` from an agent-mode manifest), that manifest record is dropped in the same run — the ledger becomes the owner (migration write); an emptied manifest is left as `{"patches": {}}`, never deleted. The migration is reported through the run-level `warnings[]` (stderr in human mode), never as a run error: `vendor_manifest_record_migrated` (`N manifest records moved to the vendor ledger (vendored mode is manifest-free): `) or `vendor_manifest_migration_failed` (the manifest or the ledger could not be read or rewritten; the legacy records were left in place) — so a corrupt `.socket/manifest.json` no longer fails a vendored run (standalone `vendor`, the one manifest-driven writer, still fails closed on it). With `--prune`, GC runs **after** the vendor step (the step never reads the manifest, and running the sweep last lets it reclaim what the run itself orphaned — a migrated legacy record's blobs, a superseded uuid dir). JSON output gains a `download` sub-object — the detached download envelope `{found, downloaded, skipped, failed, detached: true, patches: [{purl, uuid, action: "downloaded" | "skipped" | "failed", …}], warnings?}` (no `applied` field — nothing is applied in place; `detached: true` is pinned and always present; a `downloaded` record whose purl the ledger already holds at another uuid carries the additive `oldUuid` — the re-vendor the vendor step then performs — and its human `[fetch]` line reads ` (replacing )`) — and a `vendor` sub-object (a full vendor Envelope). Patch blobs are held in memory (see "Patch sources stay in memory" under the vendor contract). `--dry-run` previews per-patch `would_vendor` | `would_revendor` (+`oldUuid`) | `already_vendored` — plus, additive, `would_refuse` (+`errorCode`, `error`) for npm purls the wet run's Bun preflight (see the `get --mode vendored` bullet below) would refuse — without network downloads or disk writes; the preview never flips status or exit (the human path — `scan` and `get` alike, through one shared printer — prints `[would-refuse] (): ` lines behind the `--silent` gate). Interactive mode prompts "Download and vendor N patches?" (singular for one). **Vendored entries and the rest of the CLI.** Because nothing is in the manifest, vendored patches are invisible to `apply` (nothing to apply in place) but fully visible to `list` (listed from the ledger, labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode, exit 0 on a vendored-only project), `vex` (attested from the embedded records while a lockfile still wires the artifact — see "Manifest-less VEX"), `repair` (health-checked and rebuilt from the ledger), `scan --prune` (lockfile-driven reconcile) and `setup --check`'s patch-consistency property (consulted from the embedded records). They are exempt from standalone `vendor`'s manifest reconcile (`reconcile_dropped` never touches `detached` entries) and exit via `remove ` (which reverts them), `vendor --revert`, or `rollback`, whose vendored leg reverts every in-scope ledger entry (unscoped and identifier-scoped runs; path-scoped runs reach them only when an installed copy matches). The hidden `--detached` flag (`scan --vendor --detached`) names exactly this — the only — vendored posture and is accepted as a no-op for compatibility. -`scan --mode hosted` (== `--redirect`) swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither recorded nor attested. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). Re-runs over already-rewritten output record zero new edits. **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/` (and never quarantine: a `--dry-run` or a zero-grant wet run that finds a malformed `redirect-state.json` reports it as the hard error it is — exit 1, the repair-or-move-aside remedy — but moves nothing; only a run holding the lock moves it aside to `redirect-state.json.corrupt`); contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE the redirect ledger is read or written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `errorCode: "lock_held" | "lock_io"`, a string `error`, and `redirect: {mode: "hosted"}` retained (NOT the vendored `error: {code, message}` object). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: `scan --mode hosted` prints the results table and update detection like the other modes and confirms once — `Redirect N packages to the hosted patch server?` (singular for one), default yes, skipped by `--yes`/`--json`, on `--dry-run` (the engine honors the preview itself; nothing mutates), and when the detail fetch leaves nothing to redirect (that run enters the engine as a no-op — `Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/` — without prompting); without `--yes` on a non-TTY stdin the shared prompt prints `Non-interactive mode detected, proceeding automatically.` to stderr (unless `--silent`) and proceeds — before rewriting anything (parity with the agent/vendored arms and with `get --mode hosted`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine (previously `Redirected 0 packages; rewrote 0 files.`); a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). A malformed redirect ledger on a human hosted run that returns before the engine (empty discovery, nothing downloadable, a detail-fetch failure, a declined confirm) is surfaced there as the read-only `Warning: the redirect ledger … is malformed …` advisory (muted by `--silent`), never moved; the `--json` arm always enters the engine and hard-errors instead. JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang where it used to skip silently, and the ledger's `redirect_nuget_source` edit records `action: "added"` when `nuget.config` was authored from scratch (`rewritten` otherwise). Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips, and the `redirect_yarn_berry_entry` ledger edits record the lock's ON-DISK (CRLF) fragments, which the reverts match byte-exactly. A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover runs these berry gates (mixed line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) BEFORE reverting a vendored berry purl — wet and `--dry-run` alike — so a refused purl keeps its vendored wiring, ledger entry and artifact byte-identical and is skipped with the gate's code (never announced as `redirect_takeover_reverted_vendored` and then left unpatched in both modes). +`scan --mode hosted` (== `--redirect`) swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither recorded nor attested. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). Re-runs over already-rewritten output record zero new edits. **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/` (and never quarantine: a `--dry-run` or a zero-grant wet run that finds a malformed `redirect-state.json` reports it as the hard error it is — exit 1, the repair-or-move-aside remedy — but moves nothing; only a run holding the lock moves it aside to `redirect-state.json.corrupt`); contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE the redirect ledger is read or written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `errorCode: "lock_held" | "lock_io"`, a string `error`, and `redirect: {mode: "hosted"}` retained (NOT the vendored `error: {code, message}` object). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). A malformed redirect ledger on a human hosted run that returns before the engine (empty discovery, nothing downloadable, a detail-fetch failure) is surfaced there as the read-only `Warning: the redirect ledger … is malformed …` advisory (muted by `--silent`), never moved; the `--json` arm always enters the engine and hard-errors instead. JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang, and the ledger's `redirect_nuget_source` edit records `action: "added"` when `nuget.config` was authored from scratch (`rewritten` otherwise). Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips, and the `redirect_yarn_berry_entry` ledger edits record the lock's ON-DISK (CRLF) fragments, which the reverts match byte-exactly. A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover runs these berry gates (mixed line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) BEFORE reverting a vendored berry purl — wet and `--dry-run` alike — so a refused purl keeps its vendored wiring, ledger entry and artifact byte-identical and is skipped with the gate's code (never announced as `redirect_takeover_reverted_vendored` and then left unpatched in both modes). The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (`yarn.lock` entry only — `resolution: ::__archiveUrl=` + `yarnBerry10c0` checksum; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). @@ -149,16 +159,16 @@ The rewriter reads a fixed set of candidate files from the project root: the npm * `.socket/vendor/state.json` — the **vendored**-mode ledger (see "Ownership, state, and reversal" below): wiring edits with verbatim pre-vendor originals, artifact fingerprints, and the embedded patch `record` — for every entry written by `scan`/`get --mode vendored` beside `detached: true` (the record is that entry's only source), and for standalone `vendor` fed by an agent-mode manifest as a fallback copy without `detached` (the manifest record stays authoritative while the manifest covers the entry, by ledger key or base purl; `vex`, `list` and `setup --check` fall back to the embedded copy when it does not, `repair` only with no manifest at all). Entries written before 5.0 by standalone `vendor` carry no `record`; readers tolerate its absence. **Schema version 2 (v5.0)**: the `new` of a whole-file wiring record (kinds `maven_pom_repository`, `nuget_config_source`, `python_lock_document`, `python_script_metadata`, `hatch_document`) of 1 KiB or more, when its `original` is a string, is stored as an edit of that same record's `original`: `{"snapshot": "", "ops": [[start, len] | "inserted text", …]}` (the text is the ops concatenated in order: a `[start, len]` byte range copied from the `original`, a string inserted as is), and the ledger's `version` is `2`; the `original` stays a plain string, no other record kind is touched, and a ledger without such a record keeps the version-1 bytes. Both versions are read; a version-2 edit is rebuilt and checked against its hash (a mismatch, a missing `original`, an out-of-range copy, or any other `{"snapshot": …}` value is `vendor_state_unreadable`), so every consumer sees the same full texts as with an inline version-1 ledger. Records are self-contained, so an older socket-patch re-saving a version-2 ledger (it keeps `original` / `new` verbatim and drops unknown fields) loses nothing. * `.socket/vendor/redirect-state.json` — the **hosted**-mode ledger (`RedirectState` in `socket-patch-core/src/patch/redirect/state.rs`): `{ version, mode: "hosted", edits[], records{} }`. `edits` are recorded `FileEdit`s (append-only across re-runs — merge, never clobber: the pre-redirect originals a future revert needs live here; v5.0: a byte-identical re-save is skipped, which still satisfies the rule); `records` maps PURL → the full manifest `PatchRecord`, one of `vex`'s record sources for redirected patches with no manifest entry (a record attests only while a lockfile still wires its hosted patch — see "Manifest-less VEX" below). The `mode` string is opaque to the loader (pre-rename ledgers carrying `"redirect"` still load; a hosted re-run normalizes them to `"hosted"`). Written identically by this CLI and by the depscan backend's hosted PR flow (`github-patch-pr-hosted.ts`). -**get --mode and installed narrowing (v3.6).** `get --mode hosted|vendored` consumes the resolved patch(es) through the SAME engines as `scan --mode hosted|vendored`, so for the same selected (purl, uuid) set the on-disk result is identical by construction — this is the per-advisory selector hosted/vendored previously lacked (the old workaround, `get --save-only` then `vendor`, still works but is superseded). **Agent mode (v5.0 lock + residue rules)**: the download phase runs under `<.socket>/apply.lock` and hands the guard to the nested apply, so download → manifest write → apply is one lock window (the nested apply never re-acquires and inherits every caller flag — `--lock-timeout` and `--verbose` included); a failed acquire is `{status: "error", errorCode: "lock_held" | "lock_io", error}` on get's legacy envelope, exit 1, before any fetch (a read-only `.socket/` fails here, naming the lock path). `.socket/` and `.socket/blobs/` are created only when a record is actually persisted — an all-skipped or all-failed run leaves no `.socket/` on a fresh project — and a same-uuid `get ` re-run rewrites neither the manifest nor the blobs. Semantics: +**get --mode and installed narrowing (v3.6).** `get --mode hosted|vendored` consumes the resolved patch(es) through the SAME engines as `scan --mode hosted|vendored`, so for the same selected (purl, uuid) set the on-disk result is identical by construction — the per-advisory selector for hosted/vendored (`get --save-only` then `vendor` still works). **Agent mode (v5.0 lock + residue rules)**: the download phase runs under `<.socket>/apply.lock` and hands the guard to the nested apply, so download → manifest write → apply is one lock window (the nested apply never re-acquires and inherits every caller flag — `--lock-timeout` and `--verbose` included); a failed acquire is `{status: "error", errorCode: "lock_held" | "lock_io", error}` on get's legacy envelope, exit 1, before any fetch (a read-only `.socket/` fails here, naming the lock path). `.socket/` and `.socket/blobs/` are created only when a record is actually persisted — an all-skipped or all-failed run leaves no `.socket/` on a fresh project — and a same-uuid `get ` re-run rewrites neither the manifest nor the blobs. Semantics: -* **Hosted** (`get GHSA-… --mode hosted`): resolves the advisory, then hands the selected (purl, uuid) pairs to scan's hosted engine — reference grants, cross-mode takeover pre-revert, lockfile rewrite, `redirect-state.json` ledger (merge-never-clobber), gem stale-install probe, warnings, confirmation rules (cargo via `confirmed_cargo_uuids`, golang via `confirmed_golang_uuids` only) all identical to `scan --mode hosted`, and (v5.0) under the same `apply.lock` acquisition — taken around the first wet write, never on `--dry-run` or when nothing would be written; a failed acquire folds as top-level `errorCode: "lock_held" | "lock_io"` + string `error` (exit 1), and `--dry-run` under a held lock still exits 0. **No manifest write, no blobs** — the ledger is the persistence. JSON: get's legacy envelope gains the same nested `redirect` sub-object as scan's (`{mode:"hosted", redirected, rewrittenFiles, skipped, warnings, dryRun}`); the top-level shape is `{status, found, patches:[], warnings?}` — `downloaded`/`applied` are absent (nothing is downloaded into `.socket/`). Exit codes follow scan's hosted semantics: skipped grants and rewriter warnings never flip the exit; infra errors (reference fetch, corrupt/unwritable ledger, file writes) exit 1. Human prompt: `Redirect N packages to the hosted patch server?` (singular for one; get keeps its confirm gate, `--yes`/`--json`/non-TTY auto-accept as usual; as of v5.0 human `scan --mode hosted` prompts too — see the hosted section above). -* **Vendored** (`get GHSA-… --mode vendored`): the download phase is scan's vendored posture — **manifest-free (v5.0)**: the selected records are fetched into memory (`download_patch_records`; blobs held in memory; nothing under `.socket/` is written; the nested apply never runs), then scan's vendor step runs under the apply lock over exactly the selected records, like `scan --mode vendored` (no whole-manifest scope and no `[note]` about other records — that blast radius is retired with the manifest; a legacy manifest record for a vendored purl is migrated out of `.socket/manifest.json` the same way scan does it). JSON: get's envelope takes the detached download envelope's shape — `{status, found, downloaded, skipped, failed, detached: true, patches: [{purl, uuid, action: "downloaded" | "skipped" | "failed", …}], warnings?}` (`applied` is absent; `detached: true` is pinned; a `downloaded` record for a purl the vendor ledger holds at another uuid carries the additive `oldUuid`, derived from the ledger — the human `[fetch]` line reads ` (replacing )`) — and gains the nested `vendor` Envelope exactly like scan's `result["vendor"]`; a vendor-step error folds the partial envelope + `{status:"error", error:{code,message}}` in (a pre-failure takeover reconcile may have already mutated the ledger — its events must reach the consumer). Exit: download failures or vendor `has_errors` → `partial_failure`/1. Human prompt: `Download and vendor N patches?`; `--dry-run` prints `[dry-run] Would download and vendor N patches. No changes made.` on both identifier paths (uuid and search). Telemetry mirrors scan's vendored arms (`track_outcomes_for_vendor` / `track_patch_vendor_failed`). **Bun vendored preflight (additive)** — shared by `get --mode vendored` on both its paths and `scan --mode vendored`: before ANY patch download, and only when the selection holds a `pkg:npm/` purl, the download phase reads `bun.lock`/`bun.lockb` once (`preflight_vendor`) and, when the vendor backend would refuse the project — a malformed, unreadable or unsupported `bun.lockb` → `vendor_bun_lockb_invalid`; an unreadable `bun.lock` → `vendor_lockfile_missing`; a `lockfileVersion` other than 0/1/2 or a non-canonical `packages` grammar → `vendor_lockfile_version_unsupported`; `workspace:` packages in a lock below version 2 → `vendor_bun_workspace_unsupported` — every `pkg:npm/` result becomes `{action:"failed", errorCode:, error:}` with NO fetch (the patch view is never requested) and no patch record; other ecosystems' results are untouched. **Search path** (`get --mode vendored`) and `scan --mode vendored`: the records ride `patches[]` / `download.patches[]` with `downloaded: 0`, the download phase writes nothing under `.socket/` (v5.0 — a pre-existing `.socket/manifest.json`, including a record seeded for another purl, is left byte-untouched; previously the run re-serialized the manifest), the vendor step still runs over the remaining records (no event for the refused purl), exit `partial_failure`/1. **uuid path** (`get --mode vendored`): the uuid lookup is the only fetch; the run exits 1 BEFORE the vendor step with exactly `{status:"error", found:1, downloaded:0, skipped:0, failed:1, error:{code, message}, patches:[{purl, uuid, action:"failed", errorCode, error}]}` (the `error` OBJECT is the vendored-mode error shape of the vendor-step fold-in above) and writes nothing — no `.socket/` on a fresh project; human mode prints `Error (): ` on stderr. **Already-vendored exemption**: a purl is exempt from the workspace refusal only when every instance of its `name@version` in `bun.lock` is already a `.socket/vendor/npm/…` local tuple (any uuid; the digest-less 2-tuple counts) — the engine's own criterion — so in-sync re-runs, `repair`, and a superseding patch uuid on a project vendored before it grew a workspace member all flow to the engine (re-pinning an already-local tuple adds no workspace-relative exposure); a wiped ledger alone is not a refusal (the engine path decides). UUID equality in the ledger alone never exempts a purl: `rollback --preserve-state` retains its record after unwiring. Dry-run refusal takes priority over `already_vendored`. **Unreadable vendor ledger**: a `.socket/vendor/state.json` the preflight cannot read or parse is itself the refusal — `vendor_state_unreadable` with the io/parse detail, fail-closed (nothing is exempt) — on the uuid path, the search / `scan` path and the `--dry-run` preview alike; never a Bun lock code. **`--silent`** is "errors only" and never mutes the refusal: the code-tagged `[error] (): ` (per-patch paths) / `Error (): …` (uuid path) line stays on stderr with an empty stdout. **`--dry-run`** previews the refusal as the additive `would_refuse` action (see `--dry-run` below). Agent-mode `get --save-only` is NOT preflighted (record-only intent has no consumption precondition). Pinned by `tests/in_process_vendor_bun.rs` (exact uuid-path envelope, seeded-manifest survival, `--silent`, `--dry-run`) and `tests/scan_vendor_e2e.rs`. +* **Hosted** (`get GHSA-… --mode hosted`): resolves the advisory, then hands the selected (purl, uuid) pairs to scan's hosted engine — reference grants, cross-mode takeover pre-revert, lockfile rewrite, `redirect-state.json` ledger (merge-never-clobber), gem stale-install probe, warnings, confirmation rules (cargo via `confirmed_cargo_uuids`, golang via `confirmed_golang_uuids` only) all identical to `scan --mode hosted`, and (v5.0) under the same `apply.lock` acquisition — taken around the first wet write, never on `--dry-run` or when nothing would be written; a failed acquire folds as top-level `errorCode: "lock_held" | "lock_io"` + string `error` (exit 1), and `--dry-run` under a held lock still exits 0. **No manifest write, no blobs** — the ledger is the persistence. JSON: get's legacy envelope gains the same nested `redirect` sub-object as scan's (`{mode:"hosted", redirected, rewrittenFiles, skipped, warnings, dryRun}`); the top-level shape is `{status, found, patches:[], warnings?}` — `downloaded`/`applied` are absent (nothing is downloaded into `.socket/`). Exit codes follow scan's hosted semantics: skipped grants and rewriter warnings never flip the exit; infra errors (reference fetch, corrupt/unwritable ledger, file writes) exit 1. Human prompt: `Redirect N packages to the hosted patch server?` (singular for one; `--yes`/`--json`/non-TTY auto-accept as usual). This confirm is get's alone: `scan` never prompts. +* **Vendored** (`get GHSA-… --mode vendored`): the download phase is scan's vendored posture — **manifest-free (v5.0)**: the selected records are fetched into memory (`download_patch_records`; blobs held in memory; nothing under `.socket/` is written; the nested apply never runs), then scan's vendor step runs under the apply lock over exactly the selected records, like `scan --mode vendored` (no whole-manifest scope and no `[note]` about other records — that blast radius is retired with the manifest; a legacy manifest record for a vendored purl is migrated out of `.socket/manifest.json` the same way scan does it). JSON: get's envelope takes the detached download envelope's shape — `{status, found, downloaded, skipped, failed, detached: true, patches: [{purl, uuid, action: "downloaded" | "skipped" | "failed", …}], warnings?}` (`applied` is absent; `detached: true` is pinned; a `downloaded` record for a purl the vendor ledger holds at another uuid carries the additive `oldUuid`, derived from the ledger — the human `[fetch]` line reads ` (replacing )`) — and gains the nested `vendor` Envelope exactly like scan's `result["vendor"]`; a vendor-step error folds the partial envelope + `{status:"error", error:{code,message}}` in (a pre-failure takeover reconcile may have already mutated the ledger — its events must reach the consumer). Exit: download failures or vendor `has_errors` → `partial_failure`/1. Human prompt: `Download and vendor N patches?`; `--dry-run` prints `[dry-run] Would download and vendor N patches. No changes made.` on both identifier paths (uuid and search). Telemetry mirrors scan's vendored arms (`track_outcomes_for_vendor` / `track_patch_vendor_failed`). **Bun vendored preflight (additive)** — shared by `get --mode vendored` on both its paths and `scan --mode vendored`: before ANY patch download, and only when the selection holds a `pkg:npm/` purl, the download phase reads `bun.lock`/`bun.lockb` once (`preflight_vendor`) and, when the vendor backend would refuse the project — a malformed, unreadable or unsupported `bun.lockb` → `vendor_bun_lockb_invalid`; an unreadable `bun.lock` → `vendor_lockfile_missing`; a `lockfileVersion` other than 0/1/2 or a non-canonical `packages` grammar → `vendor_lockfile_version_unsupported`; `workspace:` packages in a lock below version 2 → `vendor_bun_workspace_unsupported` — every `pkg:npm/` result becomes `{action:"failed", errorCode:, error:}` with NO fetch (the patch view is never requested) and no patch record; other ecosystems' results are untouched. **Search path** (`get --mode vendored`) and `scan --mode vendored`: the records ride `patches[]` / `download.patches[]` with `downloaded: 0`, the download phase writes nothing under `.socket/` (v5.0 — a pre-existing `.socket/manifest.json`, including a record seeded for another purl, is left byte-untouched), the vendor step still runs over the remaining records (no event for the refused purl), exit `partial_failure`/1. **uuid path** (`get --mode vendored`): the uuid lookup is the only fetch; the run exits 1 BEFORE the vendor step with exactly `{status:"error", found:1, downloaded:0, skipped:0, failed:1, error:{code, message}, patches:[{purl, uuid, action:"failed", errorCode, error}]}` (the `error` OBJECT is the vendored-mode error shape of the vendor-step fold-in above) and writes nothing — no `.socket/` on a fresh project; human mode prints `Error (): ` on stderr. **Already-vendored exemption**: a purl is exempt from the workspace refusal only when every instance of its `name@version` in `bun.lock` is already a `.socket/vendor/npm/…` local tuple (any uuid; the digest-less 2-tuple counts) — the engine's own criterion — so in-sync re-runs, `repair`, and a superseding patch uuid on a project vendored before it grew a workspace member all flow to the engine (re-pinning an already-local tuple adds no workspace-relative exposure); a wiped ledger alone is not a refusal (the engine path decides). UUID equality in the ledger alone never exempts a purl: `rollback --preserve-state` retains its record after unwiring. Dry-run refusal takes priority over `already_vendored`. **Unreadable vendor ledger**: a `.socket/vendor/state.json` the preflight cannot read or parse is itself the refusal — `vendor_state_unreadable` with the io/parse detail, fail-closed (nothing is exempt) — on the uuid path, the search / `scan` path and the `--dry-run` preview alike; never a Bun lock code. **`--silent`** is "errors only" and never mutes the refusal: the code-tagged `[error] (): ` (per-patch paths) / `Error (): …` (uuid path) line stays on stderr with an empty stdout. **`--dry-run`** previews the refusal as the additive `would_refuse` action (see `--dry-run` below). Agent-mode `get --save-only` is NOT preflighted (record-only intent has no consumption precondition). Pinned by `tests/in_process_vendor_bun.rs` (exact uuid-path envelope, seeded-manifest survival, `--silent`, `--dry-run`) and `tests/scan_vendor_e2e.rs`. -**Lock-text refusals before the download (v5.0)** — shared by `get --mode vendored` on both its paths and `scan --mode vendored`, after the Bun preflight above and the ledger's `already vendored` skip: a `pkg:npm/` result in a **pnpm, yarn classic or yarn berry** project, or a `pkg:cargo/` result, that its vendor backend refuses on the project's lock and manifest text alone is refused BEFORE its patch view is fetched — the pnpm / classic / berry gates the backend runs before it reads the package (coordinates, the lock and manifest reads and their line-ending / version / `cacheKey` / `.yarnrc.yml` gates, override and `resolutions` conflicts, the lock entry present and rewritable) and cargo's `locked_version_mismatch` (only when it is the crate's FIRST refusal; an in-tree `cargo vendor` copy still refuses in the loop as `already_vendored_in_tree`). **Scope:** only a package the vendor loop would hand to its backend is refused early — one installed on disk (the loop's own qualified-aware resolver plus the npm identity lookup), or one the lockfile inventory resolves to a verifiable registry source (a lock entry with an integrity, or the ledger-recovered pre-vendor resolution — exactly the entry the pristine fetch would use). A package absent from the lock and not installed never reached its backend and is untouched: its view is fetched, it downloads, and the vendor loop skips it `skipped` / `package_not_installed` as in v4.x (so cargo's `locked_version_mismatch` is refused early only for a crate installed at the unlocked version). The result becomes `{action:"failed", errorCode:, error:}` in `download.patches[]` / `patches[]` with the backend's exact code and detail, no view and no pristine fetch, no patch record, and therefore no vendor event: compared with v4.x, `download.downloaded` drops and `download.failed` rises by the number of such packages, `vendor.summary.failed` and `vendor.events` lose their `failed` events, and a lockfile-only package among them loses its `vendor_fetched_missing` event (it is never fetched). Exit code and top-level `status` are unchanged (`partial_failure`/1); the nested `vendor.status` becomes `success` when those refusals were the vendor step's only failures (observed on the depscan fixture: 3 refusals, `partialFailure` → `success`), and when every selected package is refused this way the human `scan --vendor` arm prints `Nothing was vendored: N patches failed (see above).`. **Precedence:** the lock-text refusal is decided before the view, so it wins over every view-derived outcome — a package that would also have been a paid-access 403 (`[PAID]`/no access), a failed view fetch, or a no-applicable-files skip reports the lock refusal instead (the Bun refusal and the ledger's `already vendored` skip still come first). The human `[error] (): ` line is printed during the download instead of the vendor step's failure line (the interactive human `scan --vendor` arm's pre-prompt baseline check still fetches the views it verifies; only the download, the pristine fetch and the vendor step skip the package there). A purl the hosted redirect ledger claims keeps the loop's refusal (its takeover revert rewrites the lock the gates read), as does every purl when that ledger is malformed; other flavors (package-lock, pnpm-legacy, bun) and ecosystems are untouched, and `--dry-run` is unchanged. `vendor` (manifest-driven, no view fetch) keeps its per-package `failed` events but no longer fetches the pristine source of a lockfile-only package it refuses this way — the source is deferred to the backend, which refuses before reading it (no `vendor_fetched_missing` event and no registry request; a refused package whose registry is unreachable reports the gate's code instead of `vendor_fetch_failed`); only a package the lock resolves to a verifiable source is deferred, and one it does not resolve keeps its `package_not_installed` skip. Pinned by `tests/scan_vendor_e2e.rs` (`exact_download_plan`: scan and exact-purl get, pnpm and cargo scope), `tests/e2e_yarn_legacy_cachekey_refusal_build.rs` and `tests/vendor_rerun_no_network_e2e.rs`. +**Lock-text refusals before the download (v5.0)** — shared by `get --mode vendored` on both its paths and `scan --mode vendored`, after the Bun preflight above and the ledger's `already vendored` skip: a `pkg:npm/` result in a **pnpm, yarn classic or yarn berry** project, or a `pkg:cargo/` result, that its vendor backend refuses on the project's lock and manifest text alone is refused BEFORE its patch view is fetched — the pnpm / classic / berry gates the backend runs before it reads the package (coordinates, the lock and manifest reads and their line-ending / version / `cacheKey` / `.yarnrc.yml` gates, override and `resolutions` conflicts, the lock entry present and rewritable) and cargo's `locked_version_mismatch` (only when it is the crate's FIRST refusal; an in-tree `cargo vendor` copy still refuses in the loop as `already_vendored_in_tree`). **Scope:** only a package the vendor loop would hand to its backend is refused early — one installed on disk (the loop's own qualified-aware resolver plus the npm identity lookup), or one the lockfile inventory resolves to a verifiable registry source (a lock entry with an integrity, or the ledger-recovered pre-vendor resolution — exactly the entry the pristine fetch would use). A package absent from the lock and not installed never reached its backend and is untouched: its view is fetched, it downloads, and the vendor loop skips it `skipped` / `package_not_installed` as in v4.x (so cargo's `locked_version_mismatch` is refused early only for a crate installed at the unlocked version). The result becomes `{action:"failed", errorCode:, error:}` in `download.patches[]` / `patches[]` with the backend's exact code and detail, no view and no pristine fetch, no patch record, and therefore no vendor event: compared with v4.x, `download.downloaded` drops and `download.failed` rises by the number of such packages, `vendor.summary.failed` and `vendor.events` lose their `failed` events, and a lockfile-only package among them loses its `vendor_fetched_missing` event (it is never fetched). Exit code and top-level `status` are unchanged (`partial_failure`/1); the nested `vendor.status` becomes `success` when those refusals were the vendor step's only failures (observed on the depscan fixture: 3 refusals, `partialFailure` → `success`), and when every selected package is refused this way the human `scan --vendor` arm prints `Nothing was vendored: N patches failed (see above).`. **Precedence:** the lock-text refusal is decided before the view, so it wins over every view-derived outcome — a package that would also have been a paid-access 403 (`[PAID]`/no access), a failed view fetch, or a no-applicable-files skip reports the lock refusal instead (the Bun refusal and the ledger's `already vendored` skip still come first). The human `[error] (): ` line is printed during the download instead of the vendor step's failure line (the human (non-`--silent`) `scan --vendor` arm's baseline pre-check still fetches the views it verifies; only the download, the pristine fetch and the vendor step skip the package there). A purl the hosted redirect ledger claims keeps the loop's refusal (its takeover revert rewrites the lock the gates read), as does every purl when that ledger is malformed; other flavors (package-lock, pnpm-legacy, bun) and ecosystems are untouched, and `--dry-run` is unchanged. `vendor` (manifest-driven, no view fetch) keeps its per-package `failed` events but no longer fetches the pristine source of a lockfile-only package it refuses this way — the source is deferred to the backend, which refuses before reading it (no `vendor_fetched_missing` event and no registry request; a refused package whose registry is unreachable reports the gate's code instead of `vendor_fetch_failed`); only a package the lock resolves to a verifiable source is deferred, and one it does not resolve keeps its `package_not_installed` skip. Pinned by `tests/scan_vendor_e2e.rs` (`exact_download_plan`: scan and exact-purl get, pnpm and cargo scope), `tests/e2e_yarn_legacy_cachekey_refusal_build.rs` and `tests/vendor_rerun_no_network_e2e.rs`. * **Installed-version narrowing** (all modes, `get`'s search path): a CVE/GHSA fan-out returns one patch record per patched VERSION; get keeps only versions present here and emits calm `skipped` records (`errorCode: "package_not_installed"`) for the rest — never an error exit. Presence = installed on disk (qualified-aware resolver) ∪ already tracked in the manifest (record maintenance keeps working on hosts without an installed copy); hosted/vendored modes additionally count lockfile-resolved deps and vendor-ledger purls (mirroring scan's discovery supplements, including their `--global` gate). **Exempt** (no narrowing): UUID identifiers, exact-versioned PURL identifiers (explicit intent), `--save-only` runs (record-only has no installation precondition — the fresh-clone record→vendor flow keeps working), `--all-releases`, and the package-name path (already installed-derived). When EVERY found patch is filtered out, get exits 0 with the additive status **`not_installed`** (`{status:"not_installed", found:N, downloaded:0, applied:0, patches:[], warnings?}`) — never `no_match`, which remains pinned to the fuzzy package-name path. PnP layouts are surfaced, not misreported: yarn-PnP npm results skip with `errorCode: "yarn_pnp_unsupported"` in every mode; pnpm-PnP skips carry `pnpm_pnp_unsupported` in agent/vendored modes; hosted mode — the refusal's own remedy — keeps ONLY the versions the raw `pnpm-lock.yaml` text actually resolves (boundary-anchored probe over the v5/v6/v9 key spellings, so a large fan-out never requests grants for every version ever patched), labels a JUDGED miss `package_not_installed` exactly like a non-PnP project (the layout blocked nothing — the lock was read and the version isn't resolved), and reserves the layout code for an unreadable lock (no judgment possible). When EVERY narrowed-out result is a PnP refusal, the human terminal names the layout instead of claiming "not installed" and never advises `--all-releases` (which cannot make PnP patchable); the JSON status stays `not_installed` — consumers dispatch on the per-record `errorCode`. Hosted mode also runs the per-release VARIANT filter (`filter_to_installed_releases`) on its search path before requesting grants — agent/vendored runs get it inside the download engines — with the same keep-all-plus-warning fallbacks (surfaced as `(release_narrowing)`-prefixed strings in `warnings[]`). An ecosystem this binary has no crawler for is likewise never judged: its results are KEPT (absence from a crawl that never looked carries no information — the same fail-safe as scan's prune GC). The human `Found N patches:` listing shows only the patches whose package version survived the narrowing (the narrowing is judged over every result, so an installed package's paid fix a free user cannot download still lists as `[PAID] (no access)`, while skip records and counts cover only accessible patches), sorted by PURL in natural version order (`4.17.2` before `4.17.10`); the narrowed-out ones are summarized on stderr in one line per reason (`Skipped N patches for M package versions not installed here (use --all-releases to include them).`), and `--verbose` adds one `[skip] ()` line per skipped version after that summary, in natural version order. When the candidates hold more patches than were selected and the pick was made without a menu (a paid user's auto-pick, `--yes`, a non-TTY run), a `Selected:` block names the patch (purl, tier, short uuid, advisories) that will be installed before the prompt. Machine output (the prompt count, the JSON envelope) uses the kept set, unchanged. The finer per-release variant narrowing (`filter_to_installed_releases`) is unchanged and still runs inside the download engines (and before an agent-mode `--dry-run` preview, so the preview names only the variants a wet run would fetch). -* **Deliberate divergences from scan** (documented, not drift): get keeps its `selection_required` JSON posture for free multi-patch PURLs (scan auto-picks); get has no `--vex` (an ambient `SOCKET_VEX` is ignored by get's modes), no `--detached` (moot — `get --mode vendored` is manifest-free by construction), no `--prune`; get does not run scan's pre-confirm vendor baseline annotation; and an all-narrowed-out run exits `not_installed` without entering the vendor step (heal-after-wipe re-vendoring stays `scan --mode vendored`'s job). Agent-mode `get` honors `--dry-run` too (v5.x; it used to download, save and apply anyway): the search and uuid paths classify each selected patch against the manifest (read-only; an unreadable manifest fails closed like the wet run) and stop before the prompt, the download, any `.socket/` write and the apply — human `[would-add]` / `[would-update] … (replacing )` / `[skip] … (already in manifest)` lines then `[dry-run] Would download and apply N patches. No changes made.`; JSON `{status:"success", dryRun:true, found, downloaded:0, skipped, applied:0, patches:[{purl, uuid, action:"would_add"|"would_update"(+oldUuid)|"skipped"}, ], warnings?}`, exit 0. +* **Deliberate divergences from scan** (documented, not drift): get keeps its `selection_required` JSON posture for free multi-patch PURLs (scan auto-picks); get has no `--vex` (an ambient `SOCKET_VEX` is ignored by get's modes), no `--detached` (moot — `get --mode vendored` is manifest-free by construction), no `--prune`; get does not run scan's pre-vendor baseline annotation; and an all-narrowed-out run exits `not_installed` without entering the vendor step (heal-after-wipe re-vendoring stays `scan --mode vendored`'s job). Agent-mode `get` honors `--dry-run` too (v5.0): the search and uuid paths classify each selected patch against the manifest (read-only; an unreadable manifest fails closed like the wet run) and stop before the prompt, the download, any `.socket/` write and the apply — human `[would-add]` / `[would-update] … (replacing )` / `[skip] … (already in manifest)` lines then `[dry-run] Would download and apply N patches. No changes made.`; JSON `{status:"success", dryRun:true, found, downloaded:0, skipped, applied:0, patches:[{purl, uuid, action:"would_add"|"would_update"(+oldUuid)|"skipped"}, ], warnings?}`, exit 0. -`--dry-run` previews what `apply` / `rollback` / `scan --apply` / `repair` / `remove` — and `get` in every mode (hosted/vendored since v3.6, agent since v5.x) — would do without mutating disk. `get --mode hosted --dry-run` flows through the hosted engine's dry-run contract (no lock, no `.socket/`, no ledger write, no lockfile writes, `redirect.dryRun: true`); `get --mode vendored --dry-run` emits the same ledger-classification preview as scan's (`would_vendor` / `already_vendored` / `would_revendor`+`oldUuid` under the nested `vendor` key — plus, additive, `would_refuse` + `errorCode` + `error` for npm purls the wet run's Bun preflight would refuse: an in-sync `already_vendored` entry is exempt, as is a `would_revendor` entry whose `bun.lock` instances are all already local tuples; a purl the lock still resolves from the registry is refused like a fresh one, and the preview stays exit 0 / `status: "success"` with nothing written) before any download, and both skip the confirm prompt (nothing to confirm). In JSON mode, the envelope is populated with would-be actions and counts (`remove --dry-run` skips the confirmation prompt — there is nothing to confirm — and flips its would-be `Removed` events to `Verified` previews, so `summary.removed` stays "entries actually deleted"). `rollback --dry-run` (v5.0) previews every leg — the in-place restore verification, the vendored unwire (`Would revert/unwire vendoring for …`), the hosted unwind (the redirect engines resolve every inverse and drift check exactly like a wet run, flush nothing to disk, and claim the IN-MEMORY ledger clone exactly like a wet run — so the composed preview, per-purl reverts then whole-ledger replay, sees the same intermediate state a wet run would; the ON-DISK ledger is untouched), the manifest removals (simulated in memory), and the blob/archive GC — with no writes and no prompt. +`--dry-run` previews what `apply` / `rollback` / `scan --apply` / `repair` / `remove` — and `get` in every mode (hosted/vendored since v3.6, agent since v5.0) — would do without mutating disk. `get --mode hosted --dry-run` flows through the hosted engine's dry-run contract (no lock, no `.socket/`, no ledger write, no lockfile writes, `redirect.dryRun: true`); `get --mode vendored --dry-run` emits the same ledger-classification preview as scan's (`would_vendor` / `already_vendored` / `would_revendor`+`oldUuid` under the nested `vendor` key — plus, additive, `would_refuse` + `errorCode` + `error` for npm purls the wet run's Bun preflight would refuse: an in-sync `already_vendored` entry is exempt, as is a `would_revendor` entry whose `bun.lock` instances are all already local tuples; a purl the lock still resolves from the registry is refused like a fresh one, and the preview stays exit 0 / `status: "success"` with nothing written) before any download, and both skip the confirm prompt (nothing to confirm). In JSON mode, the envelope is populated with would-be actions and counts (`remove --dry-run` skips the confirmation prompt — there is nothing to confirm — and flips its would-be `Removed` events to `Verified` previews, so `summary.removed` stays "entries actually deleted"). `rollback --dry-run` (v5.0) previews every leg — the in-place restore verification, the vendored unwire (`Would revert/unwire vendoring for …`), the hosted unwind (the redirect engines resolve every inverse and drift check exactly like a wet run, flush nothing to disk, and claim the IN-MEMORY ledger clone exactly like a wet run — so the composed preview, per-purl reverts then whole-ledger replay, sees the same intermediate state a wet run would; the ON-DISK ledger is untouched), the manifest removals (simulated in memory), and the blob/archive GC — with no writes and no prompt. The hidden alias `--no-apply` on `get --save-only` is **part of the contract** — it does not appear in `--help` but is widely used in existing scripts. @@ -270,26 +280,25 @@ with no further human action. It does this by installing an ecosystem-native hoo matrix below). `setup --check` verifies that state; `setup --remove` reverts it. The properties below are the public contract. Each is backed by a test under -`crates/socket-patch-cli/tests/setup_*.rs`; properties not yet fully implemented are called out -explicitly and guarded by a deliberately-failing (RED) test that encodes the intended behavior — these -are the executable spec for follow-up work, **not** regressions. Changing any property below is governed -by the [semver policy](#semver-policy) (scoping `setup` by `--ecosystems` and strengthening `--check`, -in particular, are behavior changes that gate a version bump when implemented). +`crates/socket-patch-cli/tests/setup_*.rs` (`setup_contract_gaps.rs` holds the guards for the +properties that shipped after the contract was written; a failure there is a regression). Changing +any property below is governed by the [semver policy](#semver-policy). 1. **Idempotent.** Re-running `setup` on an already-configured repo changes nothing: status `already_configured`, `updated: 0`, every manifest byte-identical. *(Implemented.)* 2. **Ecosystem-scoped.** `setup`, `setup --check`, and `setup --remove` honor the global `--ecosystems` filter and act on only the named ecosystems; with no filter they act on every - detected ecosystem. *(Intended; **not yet implemented** — `setup` currently ignores `--ecosystems` - and always processes every detected ecosystem (npm + python + gem). RED-guarded.)* + detected ecosystem. *(Implemented — `eco_in_scope` gates the npm, Python, Bundler and Composer + legs on `--ecosystems`.)* 3. **Consistency after install.** Once an ecosystem is set up, its locally-installed dependencies are re-patched to match the manifest after **any** of: a dependency added, updated, or removed; **or** a new patch added to the manifest. The re-patch is carried by the ecosystem's install hook (npm - `postinstall`/`dependencies`, the Python `.pth` startup hook, the gem Bundler plugin) which runs + `postinstall`/`dependencies`, the Python `.pth` startup hook, the gem Bundler plugin, the Composer + `post-install-cmd`/`post-update-cmd` scripts) which runs `socket-patch apply` after the ecosystem's installer finishes, so patch state always reconverges with - the manifest. *(Implemented for npm/pypi/gem via the support matrix. Cargo and Go have no `setup` + the manifest. *(Implemented for npm/pypi/gem/composer via the support matrix. Cargo and Go have no `setup` hook — see "Cargo and Go: apply-only, no setup" below.)* 4. **`check` proves a correctly-patched state.** `setup --check` reports `configured` only when the @@ -363,8 +372,9 @@ in particular, are behavior changes that gate a version bump when implemented). discovery + `check` (a fresh clone inherits it without re-passing the flag). Excludes apply to npm workspace members; the repo root is never excludable.)* - **Nested workspaces (implemented).** A workspace member that is itself a workspace root is recursed - into and has its own members configured. `find_workspace_packages` re-reads each discovered - member's own `workspaces` field (bounded depth). Guarded by the nested-workspace pins in + into and has its own members configured. `collect_workspace_members` + (`socket-patch-core/src/package_json/find.rs`) re-reads each discovered member's own + `workspaces` field (bounded depth). Guarded by the nested-workspace pins in `tests/setup_invariants.rs`. ### Per-ecosystem setup support @@ -414,8 +424,8 @@ the model is **not uniform** today: vlt.json `workspaces` — a glob, a list, or named groups of either — or vlt <= 0.0.0-12's `vlt-workspaces.json`; vlt's declaration wins over the others and vlt never reads package.json `workspaces`). One repo-root invocation discovers and configures every member (pnpm and vlt: the - root package only — vlt runs the root hook once per install, even one started from a member; `setup --remove` also clears a vlt member that still carries a hook, as releases before vlt workspace support wired every member). *Single level only* — see property - 9's nested-workspace gap. + root package only — vlt runs the root hook once per install, even one started from a member; `setup --remove` also clears a vlt member that still carries a hook, as releases before vlt workspace support wired every member). A member that is itself a workspace root is recursed into + (bounded depth; see property 9). - **cwd-only (single project):** gem, pypi, composer. The crawler inspects only the project rooted at `--cwd` (pypi looks at `$VIRTUAL_ENV`, `/.venv` / `venv`, then a Poetry project's out-of-tree virtualenv(s) under Poetry's `virtualenvs.path`; composer at the vendor tree); it does **not** descend into sibling subprojects. A monorepo with several independent lockfiles in subdirectories @@ -591,7 +601,7 @@ Verbose `vendor_artifact_reused`). Service round trips are retried on transport after 2 consecutive exhausted fetches the rest of the run skips the service (`auto` builds locally, `service` refuses). -**golang service leg staging (v5.0)**: the module zip is downloaded, extracted and `h1:`-verified in a `.socket-stage` sibling and swapped into place only afterwards; a failed re-download of a WIRED, present copy keeps the copy and its `replace` directive (previously both were torn down), while a missing copy still drops the dangling directive. +**golang service leg staging (v5.0)**: the module zip is downloaded, extracted and `h1:`-verified in a `.socket-stage` sibling and swapped into place only afterwards; a failed re-download of a WIRED, present copy keeps the copy and its `replace` directive, while a missing copy still drops the dangling directive. Coverage today: **npm** (all lock flavors), **pypi** (wheel — sdist falls back / refuses), **cargo** (download + extract the `.crate`), **golang** (download + extract the module zip, verify the `h1:` @@ -716,7 +726,7 @@ to **six flavors**. | npm / bun (`bun.lock`, lockfileVersion 0, 1 or 2 — `vendor_lockfile_version_unsupported` otherwise) | (same tarball) | `bun.lock` only: the packages entry's registry 4-tuple → local 3-tuple with recomputed `sha512`; the entry's `{deps}` meta, the lock's version line and its line endings are preserved. A lock holding `workspace:` packages is refused `vendor_bun_workspace_unsupported` unless lockfileVersion is 2 — Bun 1.2–1.3 resolve a workspace member's local-tarball path relative to the MEMBER (ENOENT on our root-relative path), 1.4 relative to the lockfile, and a committed version-2 lock is the only proof every consumer runs Bun ≥ 1.4 (a deliberate over-approximation: a package declared only by the workspace root would install on version 1 too). The gate fires only on a run that would WRITE a new local tuple, so in-sync re-runs, `already_vendored` skips and `repair` rebuilds on such a lock pass. The detail names the version and the remedy: delete `bun.lock` and re-lock with Bun ≥ 1.4 (an in-place `bun install` keeps the existing lockfileVersion), or `--mode hosted`. Native binary support is described in the next row. `scan`/`get --mode vendored` apply all four refusals BEFORE downloading (see the `get --mode vendored` bullet). Bun 1.1.39–1.3.9 re-save the local tuple WITHOUT its `sha512` on any later lock re-save (`bun add`, `bun install` after a manifest change); the digest-less 2-tuple is recognised as the same wiring — an in-sync re-run stays `already_vendored` and re-pins the digest on disk (no new wiring record) when the committed artifact still holds the bytes the lock was written from — otherwise, as for any stale tuple of ours, the line is re-pinned and the fresh entry carries the new fingerprint — `repair` rebuilds through it, and `vendor --revert` / `rollback` restore the registry line over it (a 2-tuple at ANOTHER uuid is still `vendor_lock_entry_drifted`) | `bun install --frozen-lockfile`, cold cache (the local tarball's sha512 is enforced by Bun ≥ 1.3.10; 1.1.39–1.3.9 install it unverified — the committed artifact is the protection there) | | npm / bun binary (`bun.lockb`, native binary format 1, 2 or 3) | (same tarball) | Rewrite matching binary package resolutions and integrity in place; preserve topology and unrelated metadata, update binary offsets and the package metadata hash. Text `bun.lock` takes precedence. `bun_lockb_package` wiring snapshots recover pristine registry metadata for repair and support per-package revert and hosted ↔ vendored migration. Binary discovery and rewrites require no installed Bun runtime. Malformed or unsupported content refuses `vendor_bun_lockb_invalid` before download or takeover. | Frozen installs with the original compatible Bun reader; see `docs/testing/bun-compatibility.md` for the release matrix and historical runtime integrity limits. | | npm / vlt (`vlt-lock.json`, lockfileVersion 0 or 1 — A0 locks without a version and every other version refuse `vendor_lockfile_version_unsupported`; flavor `vlt`) | patched package **directory** `.socket/vendor/npm//[@scope/]-/node_modules//` (the extra `node_modules/` level lets a package `require()` its own name), its `package.json` without `devDependencies`, plus `/.gitignore` (re-includes the payload against the project's ignores, ignores vlt's links inside it) and `/.gitattributes` (`-text`) | direct dependencies of the root or a workspace member only: the lock node becomes a `file` node for the directory, its importer edges and outgoing edges are re-keyed, and each importer's `package.json` spec becomes `file:`; every moved entry lands where vlt's serializer puts it. A node whose only extra is one peer context (`ṗ:N`, `peer.N`, `peer.<16 hex>`: from vlt 1.0.8 a root dependency with resolved peers, from rc.15 a workspace member's) becomes a `file` node without the extra, as vlt writes `file:` dependencies, keeping its peer edges; revert restores the extra-bearing DepID. Refused before any write: transitive targets (`vendor_vlt_transitive_unsupported`), two or more instances of one `name@version` or a modifier extra, importer `peer` edges, foreign registries, a git, remote-tarball or local-directory node of the same package name (vlt records no version for it), a package `vlt build` would build in place (`vendor_vlt_build_scripts_unsupported`), a name declared in several dependency fields (`vendor_lock_entry_unsupported`), a spec that disagrees with the lock (`vendor_vlt_lock_out_of_sync`), a payload git would ignore (`vendor_artifact_gitignored`), a purl vendored under another flavor (`vendor_flavor_changed`); era-A locks warn `vendor_vlt_legacy_lockfile`; an optional dependency (or any dependency node_modules still links to its installed upstream copy) gets `vendor_vlt_reinstall_required` | fresh checkout, `vlt ci` with cold caches: the patched bytes load and `vlt-lock.json` stays byte-identical, also through a warm and a cold `vlt install --frozen-lockfile` (checked on 1.2.0, 1.0.10, 1.0.4, 1.0.0-rc.32 and 1.0.0-rc.14, and on every release by `docs/testing/vlt-compatibility.md`); no-op installs, `vlt install `, `uninstall` and `vlt update` keep the direct dependency vendored. `vendor --revert` restores the registry node, edges and specs, keeping what vlt re-laid since, and refuses on drift | -| cargo | crate dir `-/` (no `.cargo-checksum.json`) | (v5.0) `[patch.crates-io]` path entry in the **workspace-root `Cargo.toml`** (the manifest beside the `Cargo.lock` it detaches — never `.cargo/config*`) **+** Cargo.lock surgery (the `[[package]]` entry's `source`/`checksum` removed and its `version` set to the copy's TAGGED version `+socket.` — `+.socket.` when the version already has build metadata — with every lock reference that spells the old version rewritten, formats v1–v4; the copy's own `Cargo.toml` version carries the same tag, so the patched crate sees it in `CARGO_PKG_VERSION`; revert restores the lock byte for byte). Key: always the Socket-owned `-socket-` with `package = ""` (the full uuid hex when that key is taken), never the bare crate name — cargo lets a config-file `[patch]` item (project, ancestor directory or `$CARGO_HOME`) replace the manifest item with the same key whatever its version, so keys any of those configs use are avoided and a re-run moves an entry off a now-shadowed key; two versions of one crate are wired side by side. Pre-v5 wiring in `.cargo/config.toml` / `.cargo/config` is moved into `Cargo.toml` by a re-run (`vendor`, `scan`/`get --mode vendored`) or `repair` (`cargo_wiring_migrated` note; the ledger's `cargo_patch_entry` record then names `Cargo.toml`); a detached lock entry left unwired by the pre-v5 multi-version overwrite is re-wired the same way (`cargo_wiring_restored`); every revert removes both spellings | `cargo build --locked --offline` on a fresh checkout — single-version manifest `[patch]` also builds with no network on cargo older than 1.56 (the old config-file wiring's floor); two vendored versions of ONE crate need `--offline` on cargo 1.56 and a populated registry index (or network access) on older cargo such as 1.41, which loads the index to tell them apart. Note: path deps build **without** `--cap-lints allow` | +| cargo | crate dir `-/` (no `.cargo-checksum.json`) | (v5.0) `[patch.crates-io]` path entry in the **workspace-root `Cargo.toml`** (the manifest beside the `Cargo.lock` it detaches — never `.cargo/config*`) **+** Cargo.lock surgery (the `[[package]]` entry's `source`/`checksum` removed and its `version` set to the copy's TAGGED version `+socket.` — `+.socket.` when the version already has build metadata — with every lock reference that spells the old version rewritten, formats v1–v4; the copy's own `Cargo.toml` version carries the same tag, so the patched crate sees it in `CARGO_PKG_VERSION`; revert restores the lock byte for byte). Key: always the Socket-owned `-socket-` with `package = ""` (the full uuid hex when that key is taken), never the bare crate name — cargo lets a config-file `[patch]` item (project, ancestor directory or `$CARGO_HOME`) replace the manifest item with the same key whatever its version, so keys any of those configs use are avoided and a re-run moves an entry off a now-shadowed key; two versions of one crate are wired side by side. Pre-v5 wiring in `.cargo/config.toml` / `.cargo/config` is moved into `Cargo.toml` by a re-run (`vendor`, `scan`/`get --mode vendored`) or `repair` (`cargo_wiring_migrated` note; the ledger's `cargo_patch_entry` record then names `Cargo.toml`); a detached lock entry left unwired by the pre-v5 multi-version overwrite is re-wired the same way (`cargo_wiring_restored`); every revert removes both spellings | `cargo build --locked --offline` on a fresh checkout — single-version manifest `[patch]` also builds with no network on cargo older than 1.56 (the old config-file wiring's floor); two vendored versions of ONE crate need cargo 1.45 or newer (`--offline` from an empty CARGO_HOME is enough there); older cargo fails closed whatever the index state, and a project that does not pin cargo ≥ 1.45 (`rust-version` or toolchain file) gets the `cargo_multi_version_old_cargo` warning. Note: path deps build **without** `--cap-lints allow` | | golang | module dir `@/` | `go.mod` `replace => ./.socket/vendor/golang//@` | `go build` with `GOPROXY=off` + empty `GOMODCACHE` (directory replaces bypass go.sum entirely; survives `go mod tidy`) | | composer | package dir `/@/` | `composer.lock` only: entry's `dist` → `{type: "path", url, reference: null}`, `source` removed, `transport-options: {symlink: false}` added. `content-hash` unaffected; `composer.json` untouched | `composer install` (from the lock alone, real copy not symlink, works under `--network none`). `composer update ` reverts it | | gem | gem dir `-/` + gemspec materialized from `specifications/` | **Gemfile + Gemfile.lock pair**: the `gem` line gains `path:` (or a managed block for transitive deps); the lock's spec block moves GEM→PATH and the DEPENDENCIES entry becomes ` (= )!`, in bundler's exact canonical form | `bundle install` (normal **and** `BUNDLE_FROZEN=true`), byte-stable lock. Lock-only edits are a silent unpatch — hence the mandatory pair | @@ -734,8 +744,8 @@ Ecosystems with no vendor backend (jsr) refuse per-purl with Bun's binary `bun.lockb` is supported natively, including lockfile-only discovery, vendoring, hosting, repair and migration between those modes. A lock-less tool marker (a `[tool.uv]`/`[tool.poetry]`/ `[tool.pdm]` table or a `Pipfile` without its lock) refuses `_no_lockfile` unless a -`requirements.txt` fallback exists. PURLs of **compiled-out** ecosystems are invisible to `vendor` -exactly as they are to `apply` (the binary cannot parse them). +`requirements.txt` fallback exists. PURLs of ecosystems this binary has no backend for (e.g. a newer +CLI's ecosystem in the committed manifest) are invisible to `vendor` exactly as they are to `apply`. ### Checksum coverage @@ -956,7 +966,7 @@ Restore the system but keep the local patch state for a later re-apply: manifest | Key | Shape | Meaning | |---|---|---| | `warnings` | `[{code, detail}]` | Run-level warnings, now populated (previously always empty): `reinstall_required`, `hosted_state_not_preservable`, `out_of_scope_copies_restored`, `vendor_state_unreadable`, `redirect_state_unreadable`, `cleanup_failed`, `manifest_write_failed`, `redirect_pnpm_trust_scaffold_modified`, `redirect_npmrc_allow_remote_modified`, `ownership_not_restored` (a restored file whose ownership could not be put back — see the apply warnings), plus vendored/hosted leg advisories. New codes are additive (MINOR) | -| `vendored` | `[purl]` | **Meaning narrowed (MAJOR)**: vendor-owned purls the run did NOT act on — today exactly the corrupt-vendor-ledger skip. Previously this listed every vendor-owned skip | +| `vendored` | `[purl]` | **Meaning narrowed (MAJOR)**: vendor-owned purls the run did NOT act on — today exactly the corrupt-vendor-ledger skip. | | `vendoredReverted` | `[purl]` | Ledger entries cleanly reverted this run (unwired + artifact deleted + entry dropped; previewed on dry-run) | | `vendoredPreserved` | `[purl]` | `--preserve-state`: unwired with artifact + ledger entry kept | | `vendoredKept` | `[{purl, reason}]` | Drift-keeps — wiring drifted, vendored state (and the manifest entry) left untouched; drives exit 1 | @@ -1021,7 +1031,7 @@ State lives at `$XDG_CACHE_HOME`|`~/.cache` (Unix/macOS) or `%LOCALAPPDATA%` (Wi ## Environment variables -All v3.0 env vars use the `SOCKET_*` prefix. Three legacy `SOCKET_PATCH_*` names are still honored at runtime for compatibility: on first read of any of the three the binary emits a one-shot deprecation warning to stderr (the warning fires unconditionally — even under `--silent` / `--json` — because it's a transition signal users need to see). The legacy names will be removed in the next major release. +All v3.0 env vars use the `SOCKET_*` prefix. Three legacy `SOCKET_PATCH_*` names are still honored at runtime for compatibility: on first read of any of the three the binary emits a one-shot deprecation warning to stderr (the warning fires unconditionally — even under `--silent` / `--json` — because it's a transition signal users need to see). The legacy names will be removed in a future major release. Four `SOCKET_CLI_*` names from the sibling JS Socket CLI are additionally accepted as **peer aliases** (supported, not deprecated — no warning): `SOCKET_CLI_API_TOKEN` → `SOCKET_API_TOKEN`, `SOCKET_CLI_ORG_SLUG` → `SOCKET_ORG_SLUG`, `SOCKET_CLI_API_BASE_URL` → `SOCKET_API_URL`, `SOCKET_CLI_NO_API_TOKEN` → `SOCKET_NO_API_TOKEN`. The canonical `SOCKET_*` name always wins when both are set; promotion is silent and happens in-process before clap parses. Other socket-cli names (`SOCKET_CLI_CONFIG`, `SOCKET_CLI_API_PROXY`, `SOCKET_CLI_DEBUG`) are deliberately **not** honored. @@ -1048,19 +1058,24 @@ Empty string means unset at every layer: exported-but-empty flag-bound vars are | `SOCKET_VERBOSE` | `--verbose` / `-v` | `false` | — | | `SOCKET_SILENT` | `--silent` / `-s` | `false` | — | | `SOCKET_DRY_RUN` | `--dry-run` | `false` | — | -| `SOCKET_YES` | `--yes` / `-y` | `false` | — | +| `SOCKET_YES` | `--yes` / `-y` | `false` | Skips the prompts of `get`, `rollback`, `remove`, `setup` and `--update`; `scan` never prompts, so it has no effect there. | | `SOCKET_LOCK_TIMEOUT` | `--lock-timeout` | (none) | Seconds to wait for `apply.lock` on the lock-taking subcommands (incl. hosted/vendored `scan`/`get`); unset/`0` = single non-blocking try. | | `SOCKET_DEBUG` | `--debug` | `false` | **Renamed in v3.0** (was `SOCKET_PATCH_DEBUG`). | | `SOCKET_TELEMETRY_DISABLED` | `--no-telemetry` | `false` | **Renamed in v3.0** (was `SOCKET_PATCH_TELEMETRY_DISABLED`). | -| `SOCKET_FORCE` | `apply --force` / `-f`, `--update --force` | `false` | Local to `apply` and `--update`. | +| `SOCKET_NO_TRUST_LOCKFILE_CONFIG` | `--no-trust-lockfile-config` | `false` | Hosted mode: skip the `trustLockfile: true` write to `pnpm-workspace.yaml`. | +| `SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG` | `--no-npm-allow-remote-config` | `false` | Hosted mode: skip the `allow-remote=all` write to the project `.npmrc`. | +| `SOCKET_NO_VLT_INSTALL_CLEANUP` | `--no-vlt-install-cleanup` | `false` | Hosted mode, `rollback`, `remove`: keep stale vlt installed copies. | +| `SOCKET_FORCE` | `apply --force` / `-f`, `vendor --force` / `-f`, `--update --force` | `false` | Local to `apply`, `vendor` and `--update`. | | `SOCKET_PATCH_VERSION` | `--update ` | (latest) | Local to `--update`; the same pin `install.sh` and the gem launcher honor. Not one of the deprecated legacy `SOCKET_PATCH_*` trio. | | `SOCKET_BATCH_SIZE` | `scan --batch-size` | `500` authenticated / `100` proxy | Local to `scan`. | +| `SOCKET_SCAN_PACKAGES` | `scan --package` | (none) | Local to `scan` (v5.0); comma-separated names or purls. | | `SOCKET_SAVE_ONLY` | `get --save-only` | `false` | Local to `get`. | | `SOCKET_ONE_OFF` | `get --one-off` / `rollback --one-off` | `false` | Local to `get`/`rollback`. Both are **not yet implemented**: the flag parses (boolishly, empty-tolerant) and the command fails up front with a "not yet implemented" error, before any network or disk activity (on `rollback`, with no identifier-shaped target it instead fails "requires an identifier", equally up front). | | `SOCKET_ALL_RELEASES` | `get --all-releases` / `scan --all-releases` | `false` | Local to `get`/`scan`. Download patches for every release/distribution variant, not just the installed one. | | `SOCKET_SKIP_ROLLBACK` | `remove --skip-rollback` | `false` | Local to `remove`. Conflicts with `--preserve-state`/`SOCKET_PRESERVE_STATE` (exit 2 — see below). | | `SOCKET_PRESERVE_STATE` | `rollback --preserve-state` / `remove --preserve-state` | `false` | (v5.0) Shared by `rollback`/`remove` (boolish, empty-tolerant parse like the other bool flags): restore the system but keep the local patch state — manifest entries, vendored artifacts + ledger entries — and skip all GC. On `remove`, combining it with `--skip-rollback` is a usage error (exit 2) **whether either side is flag- or env-sourced** (`SOCKET_PRESERVE_STATE=true remove --skip-rollback` exits 2 too). | | `SOCKET_DOWNLOAD_ONLY` | `repair --download-only` | `false` | Local to `repair`. | +| `SOCKET_VENDOR_REVERT` | `vendor --revert` | `false` | Local to `vendor`. | | `SOCKET_SETUP_EXCLUDE` | `setup --exclude` | (none) | Local to `setup`; comma-separated workspace-member paths, persisted to `setup.exclude`. | | `SOCKET_VEX` | `apply --vex` / `scan --vex` / `vendor --vex` | (none) | Embedded OpenVEX output path. The `SOCKET_VEX_*` knobs (`_PRODUCT`, `_NO_VERIFY`, `_DOC_ID`, `_COMPACT`) are shared with the standalone `vex` command; on the host commands they bind to `--vex-product` etc. | | `SOCKET_VEX_OUTPUT` | `vex --output` / `-O` | (none) | Local to the standalone `vex`: document output path (required with `--json`). | @@ -1119,7 +1134,7 @@ These exist for staged rollouts and the launcher wrappers. They are **internal** | Env var | Purpose | |---|---| -| `SOCKET_PATCH_BIN` | Points the RubyGems CLI launcher and the gem Bundler plugin at an existing `socket-patch` binary (skips the download-on-first-run); also the escape hatch `apply` names when a golang-featureless binary is asked to audit Go redirects. | +| `SOCKET_PATCH_BIN` | Points the RubyGems CLI launcher and the gem Bundler plugin at an existing `socket-patch` binary (skips the download-on-first-run). | | `SOCKET_UPDATE_BASE_URL` | Points BOTH the release-metadata and asset-download routes of `--update`/the update notice at one base (mirror or test fixture) instead of `github.com` + `api.github.com`. Overriding it relaxes the downloaded binary's version self-check from hard-fail to warning. | | `SOCKET_UPDATE_STATE_DIR` | Overrides the per-user dir holding `update-check.json` + `update.lock` (tests point it into a tempdir). | | `SOCKET_UPDATE_TIMEOUT_MS` | Caps the update fetches' connect/metadata/download budgets (defaults 10 s / 30 s / 300 s; the notice's fetch defaults to 2 s). Doubles as the slow-network escape hatch. | @@ -1130,9 +1145,9 @@ These exist for staged rollouts and the launcher wrappers. They are **internal** | Legacy | Renamed to | Status | |---|---|---| -| `SOCKET_PATCH_PROXY_URL` | `SOCKET_PROXY_URL` | Honored with warning; remove in next major. | -| `SOCKET_PATCH_DEBUG` | `SOCKET_DEBUG` | Honored with warning; remove in next major. | -| `SOCKET_PATCH_TELEMETRY_DISABLED` | `SOCKET_TELEMETRY_DISABLED` | Honored with warning; remove in next major. | +| `SOCKET_PATCH_PROXY_URL` | `SOCKET_PROXY_URL` | Honored with warning; to be removed in a future major release. | +| `SOCKET_PATCH_DEBUG` | `SOCKET_DEBUG` | Honored with warning; to be removed in a future major release. | +| `SOCKET_PATCH_TELEMETRY_DISABLED` | `SOCKET_TELEMETRY_DISABLED` | Honored with warning; to be removed in a future major release. | ## CSV value parsing @@ -1223,7 +1238,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `vendored` | `skipped` | apply (every ecosystem) + scan `--apply`: the package is managed by `socket-patch vendor`; the command yields ownership (scan also skips the download). v5.0: rollback no longer yields — its vendored leg reverts these entries by default, and its `vendored: []` array is reserved-empty (a corrupt vendor ledger surfaces via the `vendor_state_unreadable` warning + exit 1 — the skip cannot name purls, since naming them needs the ledger). Scan `--apply --json` additionally surfaces one run-level `vendored_ownership_retained` warning naming the skipped purls (additive; exit/status unchanged). | | `vendor_reverted` | `removed` | remove: vendoring reverted (lock fragments restored, artifact + ledger entry gone) as part of removing the patch. | | `vendor_revert_failed` | top-level error | remove: the vendor revert failed; the manifest was NOT modified. | -| `vendor_state_retained` | `skipped` | remove `--skip-rollback`: vendor wiring + artifact deliberately left in place (the next `vendor` run reconciles the dropped entry). Also the top-level error code when `--skip-rollback` targets a vendored patch with no manifest record (every `scan`/`get --mode vendored` entry — and, v5.0, the ledger-only leftover of an earlier `remove --skip-rollback` of a manifest-tracked vendored patch, which used to answer `not_found`). | +| `vendor_state_retained` | `skipped` | remove `--skip-rollback`: vendor wiring + artifact deliberately left in place (the next `vendor` run reconciles the dropped entry). Also the top-level error code when `--skip-rollback` targets a vendored patch with no manifest record (every `scan`/`get --mode vendored` entry — and, v5.0, the ledger-only leftover of an earlier `remove --skip-rollback` of a manifest-tracked vendored patch). | | `hosted_state_retained` | (top-level error) | remove `--skip-rollback` targeting a hosted-only patch (no manifest entry): unwinding the redirect is the only possible removal, so the combination is refused (exit 1), mirroring the manifest-less vendored refusal above. | | `vendor_state_preserved` | `skipped` | remove `--preserve-state` (v5.0): lockfile unwired; artifact, ledger entry, and manifest entry all kept for a later re-apply. Rollback's counterpart is the `vendoredPreserved: []` envelope array. | | `vendor_revert_kept` | `skipped` + top-level error | remove (v5.0): the vendored revert drift-kept (`kept_artifact`), so the ledger entry AND the manifest entry were both kept. ANY drift-keep makes the run a `partialFailure` (exit 1) — part of the requested removal did not happen; when EVERY matching entry drift-kept, the top-level error carries this code (`summary.removed` stays 0; the identifier DID match, so never `not_found`). Remedy: re-run `scan --mode vendored` to normalize, then remove. Rollback's counterpart is the `vendoredKept: []` envelope array (also exit 1). | @@ -1245,7 +1260,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `unsafe_coordinates` | `failed` | vendor: purl/uuid would escape `.socket/vendor/` (tampered manifest/state); refused before any write. | | `revert_failed` | `failed` | vendor --revert: a recorded entry could not be reverted. | | `vendor_wiring_unknown_revert_blocked` | `skipped` (beside the `failed`/`revert_failed` event) | vendor --revert: the ledger entry was reconstructed by `repair` without wiring records and the live lockfile still resolves through the artifact — the revert refuses (fail-closed) instead of deleting a tarball the lock points at. Recovery: `socket-patch repair`, then restore the pre-vendor lock (or re-lock without the override) and re-run the revert. repair: an npm ledger entry whose `flavor` this release does not know (written by a newer socket-patch) is skipped, never health-checked or rebuilt, and the artifact, wiring and ledger stay as found (a lone `skipped` event; the run's exit is unaffected). Recovery: upgrade socket-patch. | -| `ecosystem_not_setup` | `skipped` | vex: the patch is applied and byte-verified but its ecosystem has no install hook configured and is not declared in the manifest's `setup.manual`, so it is omitted from the document (Property 7). Previously invisible in `--json`. | +| `ecosystem_not_setup` | `skipped` | vex: the patch is applied and byte-verified but its ecosystem has no install hook configured and is not declared in the manifest's `setup.manual`, so it is omitted from the document (Property 7). | | `stale_install` | `skipped` | vex (in-run `scan --mode hosted --vex`): a hosted stale-install probe found positively unpatched installed bytes, so the purl is omitted even under `--vex-no-verify` (see the gem / Python stale-install guards). | | `record_unavailable` | `skipped` | vex (manifest-less): a lockfile-wired patch has no local record (manifest, redirect ledger, vendor ledger) and none could be fetched — `--offline`, transport error, 404, or a refused (paid) patch. Omitted, never attested from the `socket-patch.vendor.json` marker. | | `record_mismatch` | `skipped` | vex (manifest-less): the record found for a wired patch names another package or another patch uuid than the wiring. | @@ -1466,21 +1481,30 @@ as raw strings, they sort by weekday name. A package can have several available patches; the manifest holds one record per PURL, so exactly one is chosen. Both `get` and every `scan` mode rank candidates identically (`socket_patch_core::api::ranking`), -best first: - -1. **Severity** — `critical > high > medium = moderate > low > (unknown)`, - taken as the worst severity across everything the patch fixes. -2. **Merge state** — a patch that remediates *more* advisories in one blob - leads. Inferred, not flagged: see below. -3. **Patch publish date**, most recent first — when the *patch* was - published, never the upstream package's release date. Unparseable or - absent dates sort last. -4. `tier` (paid first), then `uuid` — tiebreaks only, present so the +best first (v5.0, MAJOR): + +1. **Merged patches** (a patch naming ≥ 2 advisories; inferred, see + below), **newest first, whatever their severity**. A merged patch is + the cumulative fix for its package, so the most recent one wins + outright — even against a newer single-advisory `critical` patch. +2. **Everything else** — by **severity** (`critical > high > medium = + moderate > low > (unknown)`, the worst severity across everything the + patch fixes), then **patch publish date**, most recent first. +3. `tier` (paid first), then `uuid` — tiebreaks only, present so the order is total and therefore reproducible across runs. -`tier` is an **access filter, not a ranking signal**: a free `critical` -patch outranks a paid `low` one. Paid patches are excluded outright for -callers whose `canAccessPaidPatches` is false. +"Publish date" is when the *patch* was published, never the upstream +package's release date; unparseable or absent dates sort last. + +`tier` is an **access filter, not a ranking signal**: paid patches are +excluded before ranking for callers whose `canAccessPaidPatches` is +false, so the winner is the best patch the account can download. + +`scan`'s `[UPDATE]` marker and `updates[]` use the same order +(`ranking::batch_supersedes`): a candidate supersedes the applied patch +only on a meaningful rung — merged over unmerged, higher severity between +unmerged patches, or a real, strictly later publish date. The tier and +uuid tiebreaks and a missing date never count. #### Merge state is inferred, not reported @@ -1496,27 +1520,7 @@ Advisories are counted, **not** CVE ids: one advisory routinely carries several CVE aliases, and counting those would inflate a single-fix patch into a phantom merged one. -As of 2026-08-05 production publishes no merged patches — all 28 patches -sampled across npm/PyPI/gem/cargo covered exactly one advisory each — so -this rung is currently inert and ranking falls through to recency. The -moment a consolidated patch is published it is preferred automatically, -with no client *or* server change. - -#### Why severity sits above merge state - -The merged patch is the general preference: it fixes the most in one -shot, and only one patch per PURL can be applied, so breadth is what an -operator wants. But it must never shadow a *worse* vulnerability. If a -patch addresses a higher-severity advisory than anything the merged patch -covers, that one wins — you do not leave a critical unfixed to pick up -two extra mediums. Severity on the top rung expresses exactly that, -because a patch's severity is the worst advisory it fixes: - -| merged patch | rival patch | winner | why | -|---|---|---|---| -| high | critical | rival | higher severity available | -| critical | high | merged | merged already covers the worst | -| high | high | merged | severities tie → breadth decides | +Production published its first merged patch on 2026-09-04. This ordering is also the presentation order everywhere patches are listed — `scan --json`'s `packages[].patches[]`, `get`'s "Found @@ -1524,12 +1528,13 @@ patches:" listing, and the `selection_required` `options[]` array — so `patches[0]` for a package is the patch that would be applied, and `updates[].newUuid` names that same patch. -Free/unauthorized callers with more than one candidate for a PURL still -get the interactive picker (or `selection_required` in `--json`); the -ranking decides the presented order and hence the highlighted default, -not the outcome. `--yes` answers the picker with that default without -showing it (the same pick a non-terminal run makes); `--json` keeps -`selection_required` even with `--yes`. +`scan` never shows a picker: it always takes the top-ranked downloadable +patch. On `get`, free/unauthorized callers with more than one candidate +for a PURL still get the interactive picker (or `selection_required` in +`--json`); the ranking decides the presented order and hence the +highlighted default, not the outcome. `--yes` answers the picker with +that default without showing it (the same pick a non-terminal run makes); +`--json` keeps `selection_required` even with `--yes`. One additive key may appear on `scan --json`'s `packages[].patches[]` entries, omitted when absent: `publishedAt`, present whenever the server @@ -1540,10 +1545,10 @@ per-package results). > discovery (`packages[]`, the table, `updates[]`) is built from the > **batch** endpoint, whose response shape currently omits `publishedAt`; > the selection that `--apply` performs is built from the **by-package** -> endpoint, which carries it. Ranks 1, 2 and 4 agree across both, so the -> two only diverge for a package whose top candidates tie on severity -> *and* merge state — there the batch side falls through to the UUID -> tiebreak while apply correctly uses the date. +> endpoint, which carries it. The two diverge wherever the date decides — +> between merged patches, or between unmerged patches of equal severity — +> where the batch side falls through to the tier/UUID tiebreak while apply +> correctly uses the date. > > Live example: `pkg:npm/axios@1.6.0` has two free `HIGH` patches; > `packages[0].patches[0]` reports `0bc312a6…` (2026-03-27) while @@ -1599,7 +1604,7 @@ Exit `1` when `status` is `partialFailure` (any `events[*].action == "failed"`) |---|---| | `0` | Success | | `1` | Error (missing/invalid manifest, fetch failed, apply failed, selection cancelled in non-JSON mode, etc.) | -| `2` | Usage error: clap parse failures (unknown flag/value, missing required arg — including the clap-enforced `setup --check --remove` conflict) and the conflicts the commands enforce themselves — `scan`'s cross-mode conflicts (`--mode` combined with a DIFFERENT mode's boolean spelling, rejected in `resolve_mode_flags`), `scan PATHS` combined with `--mode hosted`/`--mode vendored` (same enforcement point), `remove --preserve-state --skip-rollback` (the no-op quadrant; flag- or env-sourced alike), an unparseable path glob on `scan`/`rollback`, `repair --offline --download-only`. `vex` also exits `2` on hard errors before document generation (see its tri-state table below). **Carve-out**: `get`'s self-enforced conflicts have always exited `1` via its error envelope (`--id`/`--cve`/`--ghsa`/`--package` multi-select, `--one-off --save-only`) and the v3.6 `--mode hosted\|vendored --save-only` conflict deliberately follows that get-internal precedent — changing the existing ones to `2` would be a MAJOR exit-code change | +| `2` | Usage error: clap parse failures (unknown flag/value, missing required arg — including the clap-enforced `setup --check --remove` conflict) and the conflicts the commands enforce themselves — `scan`'s cross-mode conflicts (`--mode` combined with a DIFFERENT mode's boolean spelling, rejected in `resolve_mode_flags`), `--detached` without vendored mode and `--mode hosted` with `--global`/`--global-prefix` (same enforcement point); in hosted/vendored `scan` (bare `scan` included), a PATH that is not a directory, a PATH glob matching no directory, and `--json` with more than one project directory (`run_project_dirs`); `remove --preserve-state --skip-rollback` (the no-op quadrant; flag- or env-sourced alike), an unparseable path glob on `scan`/`rollback`, `repair --offline --download-only`. `vex` also exits `2` on hard errors before document generation (see its tri-state table below). **Carve-out**: `get`'s self-enforced conflicts have always exited `1` via its error envelope (`--id`/`--cve`/`--ghsa`/`--package` multi-select, `--one-off --save-only`) and the v3.6 `--mode hosted\|vendored --save-only` conflict deliberately follows that get-internal precedent — changing the existing ones to `2` would be a MAJOR exit-code change | `list` returns **`0`** for an empty manifest and **`1`** for a missing manifest — these are distinct and load-bearing (a manifest-less project whose vendor or redirect ledger holds records is NOT "missing": `list` reads all three stores and exits 0 — see the `manifest_not_found` row). Every lock-taking subcommand — including `scan`/`get --mode hosted` as of v5.0 — returns **`1`** with `errorCode: lock_held` when another live socket-patch process holds `<.socket>/apply.lock`. @@ -1683,7 +1688,7 @@ launcher-gem legs are gated on the GitHub release — with its binaries and Every item in this document is locked in by at least one of: - **clap parser snapshots** in `crates/socket-patch-cli/tests/cli_parse_*.rs` — assert flag names, short forms, defaults, aliases, and CSV delimiters by calling `socket_patch_cli::Cli::try_parse_from(...)`. -- **Helper unit tests** in `crates/socket-patch-cli/src/**` (`#[cfg(test)] mod tests` blocks) — cover `looks_like_uuid`, `parse_argv_with_shortcuts`, `detect_identifier_type`, `select_patches`, `find_patches_to_rollback`, `partition_purls`, `verify_status_str`, the JSON serializers, and the terminal UI in `src/ui/` (`StatusLine` redraw/clear/`println` byte streams, `confirm_with` answers and non-interactive notes, `select_one`'s JSON/empty guards, `plural`, `truncate`, the `color_enabled` truth table, `paint`/`severity`, and `pad`/`strip_ansi` alignment). +- **Helper unit tests** in `crates/socket-patch-cli/src/**` (`#[cfg(test)] mod tests` blocks) — cover `looks_like_uuid`, `parse_argv_with_shortcuts`, `detect_identifier_type`, `select_patches`, `find_patches_to_rollback`, `partition_purls`, the JSON serializers, and the terminal UI in `src/ui/` (`StatusLine` redraw/clear/`println` byte streams, `confirm_with` answers and non-interactive notes, `select_one`'s JSON/empty guards, `plural`, `truncate`, the `color_enabled` truth table, `paint`/`severity`, and `pad`/`strip_ansi` alignment). - **Async `run()` integration tests** in `tests/cli_parse_list.rs`, `tests/cli_parse_remove.rs`, `tests/cli_parse_setup.rs` — exercise the no-network error paths and assert JSON shape via `serde_json::from_str::` + per-key assertions. If you add a new flag/subcommand/JSON key, add a test here that locks the new surface in the same PR. diff --git a/docs/design/configuration.md b/docs/design/configuration.md index a4d6486a..4bc48634 100644 --- a/docs/design/configuration.md +++ b/docs/design/configuration.md @@ -98,11 +98,11 @@ UX policy and are ignored. Requires teaching the TS zod twin (`npm/socket-patch/src/schema/manifest-schema.ts`) to model `setup`. Precedence would be flag > env > `setup.defaults` > default. -- **Env cleanup sweep** (separate task, agreed 2026-07-21): unify the four - bool-parsing dialects (`parse_bool_flag` vs stock `BoolishValueParser` on - `--all-releases`, bare clap bool on `get --one-off`, `env_truthy`'s - `1|true`-only match on the experimental gates and core's `SOCKET_OFFLINE` - reader); consider `FORCE_COLOR` as an alias for `CLICOLOR_FORCE` in +- **Env cleanup sweep**: core's direct env readers (`SOCKET_OFFLINE` in + `utils/env_compat.rs`, `SOCKET_TELEMETRY_DISABLED` in `telemetry.rs`) + still match only `1|true`, unlike `parse_bool_flag`'s vocabulary (the CLI + mirrors `--offline` into `SOCKET_OFFLINE=1`, so only a hand-set env value + sees the narrower dialect); consider `FORCE_COLOR` as an alias for `CLICOLOR_FORCE` in `ui::color_enabled` (which already honors `NO_COLOR`, `CLICOLOR`, `CLICOLOR_FORCE` and `TERM=dumb`); document `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY` support in the README. diff --git a/docs/design/golang-hosted-no-go.md b/docs/design/golang-hosted-no-go.md index b8f61912..154f6d01 100644 --- a/docs/design/golang-hosted-no-go.md +++ b/docs/design/golang-hosted-no-go.md @@ -103,12 +103,12 @@ steps: The token enters through the CI secret store, never a committed file; the runner is discarded so no drifted `go env` survives; and `GOPRIVATE` is scoped to the single patched module so sumdb verification stays on for the rest of -the graph. This recipe is documentation-only — neither `scan --redirect` nor +the graph. This recipe is documentation-only — neither `scan --mode hosted` nor the backend PR flow will ever write it into a repository. ## Decision -`scan --redirect` (and the backend hosted PR flow) emit +`scan --mode hosted` (and the backend hosted PR flow) emit `redirect_golang_unsupported` naming the remedy — run `socket-patch vendor` (committable, offline-verified) — and the golang dependency is otherwise left untouched. Vendored mode already gives Go users everything hosted mode diff --git a/docs/ecosystems.md b/docs/ecosystems.md index fc876705..d1d6d3ca 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -24,8 +24,7 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. | Composer (`composer`) | ✅ post-install script events | ✅ `composer.lock` `dist: path` rewrite | ✅ `composer.lock` dist url + shasum rewrite | | Deno (`deno`) | ✅ apply-only — no install hook (`setup` reports `no_files`); declare in `setup.manual` for VEX coverage | ❌ refused (`vendor_unsupported_ecosystem`) | ❌ not supported | -> **Maven / NuGet sidecar caveat**: Maven and NuGet are fully enabled in every mode (the -> old `SOCKET_EXPERIMENTAL_MAVEN` / `SOCKET_EXPERIMENTAL_NUGET` opt-ins are retired). +> **Maven / NuGet sidecar caveat**: Maven and NuGet are fully enabled in every mode. > In-place (agent-mode) patching leaves the caches' own checksum sidecars stale: NuGet's > post-apply fixup deletes `.nupkg.metadata` and raises an advisory for the > signed-package `.nupkg.sha512` tamper marker it cannot honestly rewrite; Maven's diff --git a/docs/testing/bun-compatibility.md b/docs/testing/bun-compatibility.md index 3c45a880..d4748450 100644 --- a/docs/testing/bun-compatibility.md +++ b/docs/testing/bun-compatibility.md @@ -452,7 +452,7 @@ table above. | Claim | Real-Bun matrix (`backtest-bun.py`) | Real-Bun hermetic suites (`ci.yml` `e2e`) | Bun-less unit / CLI tests | |---|---|---|---| -| Text lock 0 / 1 / 2 rewritten and installed, both modes | 1.1.39–1.4.2 | `e2e_redirect_bun_build` + `e2e_vendor_bun_build` on 1.4.2 (3 OS), 1.1.45 and 1.2.23 (Linux); the fixture asserts the lock version matches the era table, the v1-on-1.4 leg proves a committed v1 lock keeps installing | goldens `lock-v0`, `basic` (v1), `lock-v2`; `bun_lock.rs`, `lock_inventory.rs` | +| Text lock 0 / 1 / 2 rewritten and installed, both modes | 1.1.39–1.4.2 | `e2e_redirect_bun_build` + `e2e_vendor_bun_build` on 1.4.2 (3 OS), 1.1.45 and 1.2.23 (Linux); the fixture asserts the lock version matches the era table, the v1-on-1.4 leg proves a committed v1 lock keeps installing | goldens `lock-v0`, `basic` (v1), `lock-v2`; `bun_lock.rs`, `lock_inventory/bun.rs` | | Native binary formats 1 / 2 / 3: inventory, hosted, vendored, repair, takeovers, rollback | `direct`, `legacy-lockb`, conversion shapes; dedicated `backtest-bun-lockb.py` | `e2e_bun_lockb` across writer / reader revisions | `bun_lockb.rs` and committed real binary fixtures; native CLI tests | | Version-0 workspace hosted refusal + remedy | `text-workspace` (1.1.39–1.1.45) | — | golden `lock-v0-workspace-refusal` (+ `expected-warnings.json`), `redirect/mod.rs` unit tests | | Pre-v2 workspace vendored refusal (policy) + remedy; version 2 supported incl. nested | v1: 1.2.0–1.3.14 `workspace*`; v0: `text-workspace`; v2: 1.4.x | `e2e_vendor_bun_build` scoped leg (deps + bin meta survive) | `bun_lock.rs` (`legacy_workspace_tarballs_refuse_before_writes`, in-sync / rebuild exemptions), `in_process_vendor_bun`, `repair_vendor_flavors_e2e` over {0, 1, 2} × workspace shapes | diff --git a/docs/testing/hosted-production-e2e.md b/docs/testing/hosted-production-e2e.md index 7a43b921..f8af9328 100644 --- a/docs/testing/hosted-production-e2e.md +++ b/docs/testing/hosted-production-e2e.md @@ -38,14 +38,15 @@ from the child environment. | Ecosystem | PURL | Patch UUID | Advisory | Used by | |-----------|------|------------|----------|---------| -| npm | `pkg:npm/minimist@1.2.2` | `80630680-4da6-45f9-bba8-b888e0ffd58c` | GHSA-xvch-5gv4-984h / CVE-2021-44906 | all five npm-family legs | +| npm | `pkg:npm/minimist@1.2.2` | `80630680-4da6-45f9-bba8-b888e0ffd58c` | GHSA-xvch-5gv4-984h / CVE-2021-44906 | every npm-family leg (npm, npm-shrinkwrap, pnpm, yarn classic, yarn berry, bun, vlt) | | PyPI | `pkg:pypi/urllib3@1.26.18` | `de58c8b8-796c-4b6d-8a48-539b5563db76`, `26242e35-f867-4da8-8789-f0d2ea49e0f1`, `e828efa5-5c6d-43f3-9909-03f5ac232b98` | GHSA-38jv-5279-wg99, GHSA-2xpw-w6gg-jr37, GHSA-gm62-xv2j-4w53 | requirements.txt, uv.lock | | RubyGems | `pkg:gem/activestorage@6.0.3` | any of `15e960b5-f432-4b6c-b8aa-534a2b419323` (GHSA-m42x-37p3-fv5w / CVE-2020-8162), `6c4141c5-1535-4fd2-9db1-b5f8e4834bdb` (GHSA-w749-p3v6-hccq / CVE-2022-21831, published 2026-08-19), `eeb6bf9f-96c0-4963-a0f1-2e88f91f8b1a` (GHSA-9xrj-h377-fr87 / CVE-2026-33195, published 2026-08-20), `c1a1cd3c-b670-4e44-b4fa-1a63ecd42db6` (GHSA-r4mg-4433-c7g3 / CVE-2025-24293, published 2026-08-20), `9c2b4925-b413-4a3a-bb3a-9990440fb446` (GHSA-xr9x-r78c-5hrm / CVE-2026-66066, published 2026-08-21), `01019627-b481-4bae-bc09-e93b5a5e4481` (MERGED: CVE-2022-21831 + CVE-2025-24293 + CVE-2026-66066, published 2026-09-04) | see UUID column | bundler leg | -urllib3 1.26.18 carries **three** distinct free patches, one per advisory. Which -one the resolver returns is a server-side ordering detail, so the suite accepts -any of the three rather than pinning one — pinning would go red on an unrelated -server-side reorder. +urllib3 1.26.18 carries **three** distinct free patches, one per advisory. The +CLI picks one with its own ranking +(`crates/socket-patch-core/src/api/ranking.rs`) over server-supplied severity +and publish dates, so a newly published or re-scored patch can change the pick; +the suite therefore accepts any of the three rather than pinning one. `preflight_required_patches_are_published` checks all three every run and fails first with the offending PURL named, so a withdrawn patch produces one clear @@ -71,7 +72,7 @@ failure instead of N confusing ones that look like CLI regressions. | Ecosystem | Hosted mode | Free patches in production | Suite coverage | |-----------|-------------|----------------------------|----------------| | npm | ✅ | ✅ many | ✅ npm, npm-shrinkwrap, pnpm, yarn classic, yarn berry, bun; vlt probe-driven (see [vlt](#vlt-the-serve-encoding-gate)) | -| PyPI | ✅ (requirements.txt, uv.lock, Pipfile.lock) | ✅ many | ✅ requirements.txt, uv.lock, Pipfile.lock | +| PyPI | ✅ (requirements.txt, uv.lock, poetry.lock, pdm.lock, Pipfile.lock) | ✅ many | ✅ requirements.txt, uv.lock (poetry.lock / pdm.lock / Pipfile.lock via per-release backtests, see below) | | RubyGems | ✅ | ✅ (this suite pins one purl/UUID: `activestorage@6.0.3`; the 2026-08-18 republish covers more versions) | ✅ full bundler install proof | | Cargo | ✅ | ❌ **none** (tier emptied 2026-08-28) | canary only | | Maven | ✅ | ❌ **none** | canary only | @@ -186,26 +187,13 @@ integrity rejection, peer instances, and rollback across pnpm majors 1–12. The production pnpm test proves installation from the public hosted service; it does not test the SBOM backend, dashboard badges, policies, or alert counts. -### 3. `uv.lock` — the `sdist` entry is rewritten to a wheel URL (CLI, minor) +### 3. `uv.lock` — the `sdist` entry was rewritten to a wheel URL (CLI) — FIXED -The uv.lock rewriter points the `sdist` entry at the patched **wheel** and keeps -the original sdist's `size`, producing an entry whose URL, hash and size are -mutually inconsistent: - -```toml -# pristine -sdist = { url = ".../urllib3-1.26.18.tar.gz", hash = "sha256:f8ecc1bb…", size = 305687 } -wheels = [{ url = ".../urllib3-1.26.18-py2.py3-none-any.whl", hash = "sha256:34b97092…", size = 143835 }] - -# after scan --mode hosted -sdist = { url = "…patch.socket.dev/…-py2.py3-none-any.whl", hash = "sha256:ccc9a9e0…", size = 305687 } -wheels = [{ url = "…patch.socket.dev/…-py2.py3-none-any.whl", hash = "sha256:ccc9a9e0…", size = 143835 }] -``` - -uv tolerates it today because it prefers the wheel, so the leg passes. It would -bite on a `--no-binary` resolve or a platform with no matching wheel. The -rewriter should either leave `sdist` alone or update its `size` alongside the -URL and hash. +The uv.lock rewriter used to point the `sdist` entry at the patched wheel while +keeping the original sdist's `size`. It now drops the entry's existing +`sdist` / `wheel` / `wheels` / `archive` keys and writes back only the +redirected artifact (a wheel goes in `wheels`), pinned by the urllib3 1.26.18 +unit test in `crates/socket-patch-core/src/utils/python_lock.rs`. ## Running diff --git a/docs/testing/pipenv-compatibility.md b/docs/testing/pipenv-compatibility.md index d758b72a..df7a0144 100644 --- a/docs/testing/pipenv-compatibility.md +++ b/docs/testing/pipenv-compatibility.md @@ -77,9 +77,8 @@ import on modern Pythons); 2018–2022 on Python 3.8; 2023+ on Python 3.12. interpreter's basename on 2026, the full string on 2018 and 11). The crawler reproduces it (and honours the `.venv` file pointer, `PIPENV_CUSTOM_VENV_NAME`, `PIPENV_PIPFILE`, `WORKON_HOME` and Pipenv's - case-insensitive-filesystem fallback) so a bare `scan`/`rollback` sees the - project's venv; before, it fell through to the global interpreter and - reported success while the venv stayed unpatched. + case-insensitive-filesystem fallback) so an agent-mode `scan`/`rollback` + run outside `pipenv run` sees the project's venv. - **CLI scope.** The CLI reads `/Pipfile.lock` and discovers the project's venv from that directory; it does not walk up to a parent Pipfile the way Pipenv does (`PIPENV_MAX_DEPTH`) and does not follow @@ -121,7 +120,7 @@ rejection, what `pipenv lock` does to the entry, rollback after that relock hybrid that still carries our reference rolls back to the original entry), `vex`, and a byte-exact `rollback`. Agent mode additionally checks that repeat installs and `sync` keep the in-place patch, and the out-of-tree leg -requires the bare scan to see Pipenv's venv. +requires an agent-mode scan run outside `pipenv run` to see Pipenv's venv. ## Results diff --git a/docs/testing/uv-compatibility.md b/docs/testing/uv-compatibility.md index e8a5c414..86b3f4e8 100644 --- a/docs/testing/uv-compatibility.md +++ b/docs/testing/uv-compatibility.md @@ -276,7 +276,7 @@ record from the ledgers or the patch API, and verifies the installed tree manifest, reverted and half-reverted pairs (including script pairs), tampered installed trees and wheel members, spoofed hosts and vendor paths, record mismatches, and the embedded `apply --vex` / `vendor --vex` / - `scan --redirect|--vendor --vex` paths. + `scan --mode hosted|vendored --vex` paths. - `e2e_redirect_uv_build` (hosted, wiremock patch API serving the patched wheel) and `e2e_vendor_pypi_build` (vendored) — the REAL uv under test (`SOCKET_PATCH_UV_E2E_BIN` / `_VERSION` / `_PYTHON` / `_REQUIRED`) builds diff --git a/docs/testing/vendored-production-e2e.md b/docs/testing/vendored-production-e2e.md index 8ec8191b..0d694500 100644 --- a/docs/testing/vendored-production-e2e.md +++ b/docs/testing/vendored-production-e2e.md @@ -48,8 +48,8 @@ three every run and fails first with the offending PURL named. | Ecosystem | PURL | Patch UUID | Marker in the patched bytes | |-----------|------|------------|-----------------------------| | npm | `pkg:npm/minimist@1.2.2` | `80630680-4da6-45f9-bba8-b888e0ffd58c` | `Socket Community Patch` header | -| PyPI | `pkg:pypi/urllib3@1.26.18` | one of three (server-ordered) | `Socket Community Patch` header | -| RubyGems | `pkg:gem/activestorage@6.0.3` | any of the `GEM_PATCHES` table (4 as of 2026-08-20 — see the hosted doc's UUID list; each patch marks a different file) | `Socket Community Patch` header | +| PyPI | `pkg:pypi/urllib3@1.26.18` | one of three (picked by the CLI's `api::ranking`; the suite accepts any) | `Socket Community Patch` header | +| RubyGems | `pkg:gem/activestorage@6.0.3` | any of the `GEM_PATCHES` table (each patch marks a different file). The hosted doc lists 6 live UUIDs; `GEM_PATCHES` carries only the first 4 and lacks `9c2b4925` and the merged `01019627`, which v5 ranking now prefers | `Socket Community Patch` header | If a required patch is withdrawn, update the catalog constants at the top of `e2e_vendored_production.rs` **and** the table above (same procedure as the diff --git a/gem/socket-patch-bundler/README.md b/gem/socket-patch-bundler/README.md index d897673b..e30f56fc 100644 --- a/gem/socket-patch-bundler/README.md +++ b/gem/socket-patch-bundler/README.md @@ -7,7 +7,8 @@ gem patches recorded in your project's `.socket/manifest.json` applied on every > **Status: Phase 2 (scaffolding).** `socket-patch setup` currently wires the gem > ecosystem by committing an in-tree copy of this plugin under -> `.socket/bundler-plugin/` and referencing it from the `Gemfile` via `git:`. +> `.socket/bundler-plugin/` and referencing it from the `Gemfile` via a `path:` +> source (`plugin 'socket-patch', path: File.expand_path('.socket/bundler-plugin', __dir__)`). > This published gem is the planned replacement; once it is published to > RubyGems, a follow-up switches the generated `Gemfile` directive to > `plugin "socket-patch-bundler", "~> "`. @@ -22,10 +23,11 @@ the cargo build-time guard. Two triggers feed one idempotent applier: a load-time pass (covers cached/no-op installs) and an `after-install-all` hook (covers fresh installs). A digest of -the manifest + committed `.socket/` files + `Gemfile.lock` gates the work, and a -stamp under `Bundler.bundle_path` travels with the gems. On any patch failure it -raises `Bundler::BundlerError` so the build fails loudly rather than shipping -unpatched gems. +the manifest + committed `.socket/` files + `Gemfile.lock` + the patch-target +files gates the work; the digest is cached in `.socket/gem-plugin-stamp` +(machine-local, safe to gitignore or delete). On a patch failure it warns +(naming the failure and the remediation) and lets `bundle install` continue; +set `SOCKET_PATCH_STRICT=1` to raise `Bundler::BundlerError` instead. ## License diff --git a/tests/docker/README.md b/tests/docker/README.md index dd056df4..67611479 100644 --- a/tests/docker/README.md +++ b/tests/docker/README.md @@ -3,33 +3,28 @@ This directory contains the Dockerfiles and per-ecosystem fixtures used by the `tests/docker_e2e_*.rs` integration tests. Each test installs a real package via its native package manager inside a Linux container -and runs `socket-patch scan` (and, for npm, the full apply chain) -against a wiremock-served patch fixture. +and drives the full `socket-patch scan` → `apply` chain against a +wiremock-served patch fixture, verifying the patched bytes on disk. ## What's tested | Ecosystem | Real installer command | Test depth | |-----------|---------------------------------------------------------------|---------------------------| -| npm | `npm install minimist@1.2.2` | install + scan + apply + verify patched marker on disk | -| pypi | `pip install pydantic-ai==0.0.36` (in venv) | install + scan discovery | -| gem | `gem install activestorage -v 5.2.0` (vendor/bundle) | install + scan discovery | -| composer (vendor) | `composer update` (psr/log 3.0.x) | `docker_e2e_vendor_composer`: vendor → fresh-checkout `composer install --network none` → revert (see below) | -| gem (vendor) | `bundle install` (rack ~> 3.1, bundler ~> 2.7) | `docker_e2e_vendor_gem`: vendor → fresh-checkout frozen `bundle install --network none` → revert (see below) | -| cargo | `cargo fetch` with `serde = "=1.0.200"` in Cargo.toml | install + scan discovery | -| golang | `go mod download github.com/gin-gonic/gin@v1.9.1` | install + scan discovery | -| maven | `mvn dependency:get -Dartifact=org.apache.commons:commons-lang3:3.12.0` | install + scan discovery | -| composer | `composer require monolog/monolog:3.5.0` | install + scan discovery | -| nuget | `dotnet add package Newtonsoft.Json --version 13.0.3` | install + scan discovery | - -The "scan discovery" tests assert that: -1. The package manager's installed-package layout is what we expect. -2. socket-patch's crawler discovers that layout. -3. The crawler reports the installed PURL to the (mocked) Socket API. -4. The wiremock's batch-search response flows back into scan's - discovery output (`packagesWithPatches >= 1`). - -The npm test goes further and asserts the file on disk has been -overwritten with the patched bytes. +| npm | `npm install minimist@1.2.2` | install + scan + apply + rollback | +| pypi | `pip install six==1.16.0` (venv + system site-packages) | install + scan + apply + verify | +| gem | `gem install colorize -v 1.1.0` (vendor/bundle + system) | install + scan + apply + verify | +| cargo | `cargo fetch` with `cfg-if = "=1.0.0"` in Cargo.toml | install + scan + apply + verify | +| golang | `go mod download github.com/gin-gonic/gin@v1.9.1` | install + scan + apply + verify | +| maven | `mvn dependency:get -Dartifact=org.apache.commons:commons-lang3:3.12.0` | install + scan + apply + verify | +| composer | `composer require monolog/monolog:3.5.0` (local + global) | install + scan + apply + verify | +| nuget | `dotnet add package Newtonsoft.Json --version 13.0.3` (local + global) | install + scan + apply + verify | +| deno | `deno install` of `minimist@1.2.2` into `node_modules/`, plus a synthetic JSR cache layout | install + scan + apply; JSR layout scan discovery | + +Each suite asserts the installed-package layout is what the crawler +expects, that scan discovers the patch from the (mocked) Socket API, and +that apply overwrites the installed file with the patched bytes (a +`SOCKET-PATCH-E2E-MARKER` grep on disk). The vendor capstones are +described below. ## Running locally @@ -46,14 +41,14 @@ docker build -f tests/docker/Dockerfile.npm -t socket-patch-test-npm:latest . # Run a single ecosystem test: cargo test -p socket-patch-cli --features docker-e2e --test docker_e2e_npm -# Run all 8 ecosystem tests (slow — ~3 min total): -for eco in npm pypi gem cargo golang maven composer nuget; do +# Run all 9 ecosystem tests (slow): +for eco in npm pypi gem cargo golang maven composer nuget deno; do docker build -f tests/docker/Dockerfile.$eco -t socket-patch-test-$eco:latest . done cargo test -p socket-patch-cli --features docker-e2e \ --test docker_e2e_npm --test docker_e2e_pypi --test docker_e2e_gem \ --test docker_e2e_cargo --test docker_e2e_golang --test docker_e2e_maven \ - --test docker_e2e_composer --test docker_e2e_nuget + --test docker_e2e_composer --test docker_e2e_nuget --test docker_e2e_deno ``` A default `cargo test` (no `--features docker-e2e`) skips this entire @@ -61,9 +56,19 @@ suite. Developers who aren't editing the test infra never need Docker. ## Vendor capstone suites (`docker_e2e_vendor_*`) -`tests/docker_e2e_vendor_composer.rs` and `tests/docker_e2e_vendor_gem.rs` -prove the CLI_CONTRACT "Vendor command contract" rows against the real -package managers. Unlike the scan→apply suites they are MULTI-STAGE: a host +Five suites prove the CLI_CONTRACT "Vendor command contract" rows against +the real package managers, each in its ecosystem's image: + +| Suite | Image | Tooling | +|-------|-------|---------| +| `docker_e2e_vendor_composer` | `composer` | composer 2, psr/log 3.0.x | +| `docker_e2e_vendor_gem` | `gem` | bundler ~> 2.7, rack ~> 3.1 | +| `docker_e2e_vendor_maven` | `maven` | Apache Maven + JDK, commons-text 1.10.0 | +| `docker_e2e_vendor_nuget` | `nuget` | .NET SDK 8.0, Newtonsoft.Json 13.0.3 | +| `docker_e2e_vendor_pypi_pm` | `pypi` | poetry, pdm, pipenv on six 1.16.0 | + +CI's `e2e-docker` job runs the composer, nuget and pypi_pm capstones; +`coverage-docker` runs all five. Unlike the scan→apply suites they are MULTI-STAGE: a host tempdir is bind-mounted at `/workspace` and shared across three `docker run`s (networked fixture install + offline `socket-patch vendor`; then a fresh-checkout install under `--network none` with cold caches; then @@ -84,15 +89,15 @@ rebuild `Dockerfile.base` after changing vendor code or the runs test a stale binary. Note `Dockerfile.gem` is built on the official ruby image with bundler pinned `~> 2.7` (the series the gem vendor lock grammar was spike-validated against; bundler >= 2.7 needs ruby >= 3.2, newer than -Debian 12's apt ruby). The gem suite runs against the default no-CHECKSUMS -lock — the bundler >= 2.6 `lockfile_checksums` variant is a follow-up -(see the TODO in `docker_e2e_vendor_gem.rs`). +Debian 12's apt ruby). The gem suite covers both the default no-CHECKSUMS +lock and a `lockfile_checksums` twin (`bundle lock --add-checksums`). ## Bundler-version matrix images (`Dockerfile.gem-b1`, `Dockerfile.gem-b4`) The plain `Dockerfile.gem` pins bundler `~> 2.7`. Two sibling images cover -the ends of the bundler spectrum for the setup-matrix gem legs in -`crates/socket-patch-cli/tests/setup_matrix_gem.rs`: +the ends of the bundler spectrum. Only `gem-b1` feeds a gated leg (in +`crates/socket-patch-cli/tests/setup_matrix_gem.rs`); `gem-b4` is a manual +bundler-4 image with no gated leg: - `Dockerfile.gem-b1` — ruby 3.1 + **bundler 1.17.3** (last 1.x). Drives the bundler `>= 2.2` plugin floor: gem `setup` must refuse to wire a 1.x @@ -134,15 +139,17 @@ SOCKET_PATCH_TEST_HOST=1 cargo test -p socket-patch-cli \ ## CI -`.github/workflows/ci.yml` runs an `e2e-docker` matrix across all 8 +`.github/workflows/ci.yml` runs an `e2e-docker` matrix across all 9 ecosystems on every PR. Each matrix slot: -1. Builds the base image (cached via GitHub Actions cache, - `type=gha,scope=test-base`). -2. Builds the per-ecosystem image (cached per ecosystem). -3. Runs the matching `docker_e2e_` test. - -The existing `e2e` job (which hits the real Socket API) stays for -manual / scheduled real-API smoke runs. +1. Builds the base image (no GitHub Actions cache — left out on purpose + because of zizmor's cache-poisoning audit). +2. Builds the per-ecosystem image. +3. Runs the matching `docker_e2e_` test, plus that ecosystem's vendor + capstone where it has one in this job (composer, nuget, pypi_pm). + +The separate `e2e` job is the per-PR real-toolchain host matrix. The +live-API smoke suites (`e2e_npm`, `e2e_pypi`, `e2e_gem`, `e2e_scan`) are +not in CI; run them by hand with `--ignored`. ## Adding a new ecosystem diff --git a/tests/setup_matrix/README.md b/tests/setup_matrix/README.md index bd46496f..adf8b8a1 100644 --- a/tests/setup_matrix/README.md +++ b/tests/setup_matrix/README.md @@ -5,9 +5,9 @@ This suite verifies the **intended** end-to-end behavior of package-manager install applies the project's patches *on its own*, with no explicit `scan`/`apply` step. -It is **experimental and non-blocking**. `setup` only configures -npm-family install hooks today, so most non-npm cases are *expected to -fail*. The suite encodes the **aspirational** end state and records a +It is **experimental and non-blocking**. `setup` configures npm-family, +pip/uv/hatch (the `.pth` hook), Bundler (plugin) and Composer hooks; the +remaining ecosystems are the *expected-to-fail* `known_gap` cases. The suite encodes the **aspirational** end state and records a per-case **baseline** of what works now — the failing cases are a TODO list for `setup`, not a broken test. @@ -118,6 +118,7 @@ and the recorded `baseline`: | `known_gap` | fails the ideal, exactly as recorded — expected today, non-blocking | | `progress` | better than the recorded baseline — update `baseline_supported` in `matrix.json`! | | `regression` | diverged from the baseline the wrong way — the only thing that fails the runner | +| `known_regression` | would be a regression, but is on the matrix.json `known_regressions` allowlist (tracked, non-blocking) | | `error` | the driver produced no parseable result | The Rust wrappers (`tests/setup_matrix_.rs`) assert the **ideal** From d6438a3bf8d17bfeabca8e9da8636abb595d5eae Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Sun, 27 Sep 2026 16:43:12 -0400 Subject: [PATCH 10/32] Clean up stale and narrative comments across the tree Comments and doc comments now describe the current code: - references to renamed or deleted functions, files, tests and flags are fixed; - notes about pre-v5 scan behavior (prompts, report-only non-TTY scans, severity-first ranking, PATH rejection) are gone; - TODOs for finished work are removed; - history narration ("used to", bug diaries, PR and audit tags, dated verification notes) is rewritten as the invariant it protects; - doc comments attached to the wrong item are moved. Two CI path filters that named the deleted vendor/lock_inventory.rs now match vendor/lock_inventory/**. No code behavior changes. Co-Authored-By: Claude Opus 5.5 (1M context) --- .cargo/config.toml | 2 +- .github/workflows/bun-compatibility.yml | 2 +- .github/workflows/ci.yml | 90 +- .github/workflows/pdm-compatibility.yml | 2 +- .github/workflows/pipenv-compatibility.yml | 2 +- .github/workflows/publish-rubygems.yml | 4 +- .github/workflows/release.yml | 3 +- .gitignore | 5 - Cargo.toml | 7 +- crates/socket-patch-cli/Cargo.toml | 3 +- crates/socket-patch-cli/src/args.rs | 15 +- crates/socket-patch-cli/src/commands/apply.rs | 43 +- .../src/commands/fetch_stage.rs | 17 +- .../src/commands/hosted_bundle.rs | 3 +- crates/socket-patch-cli/src/commands/list.rs | 78 +- .../socket-patch-cli/src/commands/lock_cli.rs | 20 +- crates/socket-patch-cli/src/commands/mod.rs | 4 +- .../socket-patch-cli/src/commands/remove.rs | 7 +- .../socket-patch-cli/src/commands/repair.rs | 14 +- .../src/commands/repair_vendor.rs | 38 +- .../socket-patch-cli/src/commands/rollback.rs | 92 +- .../socket-patch-cli/src/commands/scan/gc.rs | 171 +-- .../src/commands/scan/hosted.rs | 466 +++----- .../src/commands/scan/hosted/python.rs | 12 +- .../src/commands/scan/hosted/vlt.rs | 5 +- .../socket-patch-cli/src/commands/scan/mod.rs | 1031 ++++++----------- .../src/commands/scan/render.rs | 14 +- .../src/commands/scan/vendor_flow.rs | 178 +-- crates/socket-patch-cli/src/commands/setup.rs | 16 +- .../socket-patch-cli/src/commands/update.rs | 12 +- .../socket-patch-cli/src/commands/vendor.rs | 596 ++++------ crates/socket-patch-cli/src/commands/vex.rs | 132 +-- .../src/commands/vex_consumed.rs | 45 +- .../src/commands/vex_sources.rs | 52 +- .../src/commands/vlt_preflight.rs | 2 +- .../src/ecosystem_dispatch.rs | 57 +- crates/socket-patch-cli/src/json_envelope.rs | 45 +- crates/socket-patch-cli/src/path_scope.rs | 9 +- crates/socket-patch-cli/src/ui/prompt.rs | 6 +- .../tests/api_client_errors_e2e.rs | 22 +- .../tests/apply_invariants.rs | 15 +- .../socket-patch-cli/tests/apply_network.rs | 32 +- .../tests/cli_apply_silent.rs | 3 - .../tests/cli_config_fallback.rs | 2 +- .../tests/cli_gem_variant_mismatch_policy.rs | 13 +- .../socket-patch-cli/tests/cli_global_args.rs | 28 +- .../socket-patch-cli/tests/cli_parse_apply.rs | 25 +- .../socket-patch-cli/tests/cli_parse_get.rs | 4 +- .../socket-patch-cli/tests/cli_parse_list.rs | 26 +- .../tests/cli_parse_repair.rs | 5 +- .../tests/cli_parse_rollback.rs | 2 +- .../socket-patch-cli/tests/cli_parse_scan.rs | 29 +- .../socket-patch-cli/tests/cli_parse_setup.rs | 6 +- .../socket-patch-cli/tests/cli_parse_vex.rs | 2 +- .../tests/cli_remove_silent.rs | 9 +- .../tests/cli_rollback_silent.rs | 3 - .../socket-patch-cli/tests/cli_scan_silent.rs | 25 +- .../tests/cli_setup_silent.rs | 10 +- .../tests/common/update_fixture.rs | 3 +- .../coverage_fix_apply_silent_mute_exit.rs | 5 +- .../tests/coverage_fix_get_double_json.rs | 12 +- ...ge_fix_rollback_ecosystem_scoped_replay.rs | 2 +- ...erage_fix_scan_discovery_corrupt_ledger.rs | 20 +- ...overage_fix_scan_hosted_dryrun_vendored.rs | 32 +- .../tests/covgap_api_client.rs | 2 +- .../tests/covgap_commands_apply.rs | 21 +- .../tests/covgap_commands_fetch_stage.rs | 19 +- .../tests/covgap_commands_list.rs | 11 +- .../tests/covgap_commands_remove.rs | 95 +- .../tests/covgap_commands_repair.rs | 4 +- .../tests/covgap_commands_repair_vendor.rs | 19 +- .../tests/covgap_commands_rollback.rs | 36 +- .../tests/covgap_commands_scan_hosted.rs | 29 +- .../tests/covgap_commands_scan_mod.rs | 50 +- .../tests/covgap_commands_scan_vendor_flow.rs | 4 +- .../tests/covgap_commands_setup.rs | 13 +- .../tests/covgap_commands_update.rs | 39 +- .../tests/covgap_commands_vendor.rs | 13 +- .../tests/covgap_commands_vex.rs | 24 +- .../tests/covgap_ecosystem_dispatch.rs | 3 +- .../socket-patch-cli/tests/covgap_output.rs | 11 +- .../tests/covgap_setup_gem_mod.rs | 2 +- .../tests/covgap_setup_gem_version.rs | 3 +- .../tests/covgap_update_download.rs | 2 +- .../tests/covgap_update_swap.rs | 2 +- .../socket-patch-cli/tests/docker_e2e_gem.rs | 2 - .../socket-patch-cli/tests/docker_e2e_npm.rs | 19 +- .../tests/docker_e2e_vendor_maven.rs | 3 +- .../tests/docker_e2e_vendor_pypi_pm.rs | 17 +- .../tests/docker_vendor_common/mod.rs | 2 +- crates/socket-patch-cli/tests/e2e_cargo.rs | 26 +- crates/socket-patch-cli/tests/e2e_composer.rs | 15 +- .../tests/e2e_embedded_vex.rs | 23 +- crates/socket-patch-cli/tests/e2e_gem.rs | 6 +- .../tests/e2e_hosted_production.rs | 73 +- crates/socket-patch-cli/tests/e2e_maven.rs | 24 +- crates/socket-patch-cli/tests/e2e_nuget.rs | 21 +- crates/socket-patch-cli/tests/e2e_pypi.rs | 2 +- .../tests/e2e_redirect_bun_build.rs | 6 +- .../tests/e2e_redirect_cargo_build.rs | 4 +- .../tests/e2e_redirect_gem_build.rs | 30 +- .../tests/e2e_redirect_npm_build.rs | 4 +- .../tests/e2e_redirect_pnpm_build.rs | 4 +- .../tests/e2e_redirect_rush_sim.rs | 5 +- .../tests/e2e_redirect_yarn_berry_build.rs | 6 +- .../tests/e2e_redirect_yarn_classic_build.rs | 6 +- .../tests/e2e_safety_advisories.rs | 39 +- .../tests/e2e_safety_cargo_build.rs | 14 +- .../socket-patch-cli/tests/e2e_safety_cow.rs | 28 +- .../tests/e2e_safety_internals.rs | 29 +- .../socket-patch-cli/tests/e2e_safety_lock.rs | 6 +- .../tests/e2e_safety_yarn_pnp.rs | 33 +- crates/socket-patch-cli/tests/e2e_scan.rs | 27 +- .../tests/e2e_vendor_bun_build.rs | 12 +- .../tests/e2e_vendor_cargo_build.rs | 32 +- .../tests/e2e_vendor_composer_build.rs | 17 +- .../tests/e2e_vendor_gem_build.rs | 7 +- .../tests/e2e_vendor_golang_build.rs | 17 +- .../tests/e2e_vendor_npm_build.rs | 2 +- .../tests/e2e_vendor_pnpm_build.rs | 10 +- .../tests/e2e_vendor_pypi_build.rs | 16 +- .../tests/e2e_vendor_yarn_berry_build.rs | 8 +- .../tests/e2e_vendor_yarn_classic_build.rs | 9 +- .../tests/e2e_vendor_yarn_classic_dev_flow.rs | 4 +- .../tests/e2e_vendored_production.rs | 43 +- crates/socket-patch-cli/tests/e2e_vex.rs | 22 +- .../tests/e2e_vex_lockfile/bun.rs | 9 +- .../tests/e2e_vex_lockfile/cargo.rs | 2 +- .../tests/e2e_vex_lockfile/deno.rs | 8 +- .../tests/e2e_vex_lockfile/golang.rs | 2 +- .../tests/e2e_vex_lockfile/npm.rs | 18 +- .../tests/e2e_vex_lockfile/nuget.rs | 4 +- .../tests/e2e_vex_lockfile/uv.rs | 8 +- .../tests/e2e_vex_lockfile/yarn.rs | 3 +- .../tests/e2e_vex_lockfile/yarn_berry.rs | 8 +- .../tests/e2e_vex_redirect.rs | 23 +- .../socket-patch-cli/tests/e2e_vex_vendor.rs | 19 +- .../tests/e2e_yarn4_pnpm_linker_build.rs | 2 +- .../tests/e2e_yarn4_workspaces_build.rs | 2 +- .../e2e_yarn_legacy_cachekey_refusal_build.rs | 6 +- .../tests/ecosystem_dispatch_e2e.rs | 21 +- .../tests/get_batch_paths_e2e.rs | 14 +- .../tests/get_edge_cases_e2e.rs | 15 +- .../socket-patch-cli/tests/get_modes_e2e.rs | 4 +- .../tests/get_update_summary_e2e.rs | 2 +- .../tests/global_packages_e2e.rs | 3 +- .../tests/hosted_symlinked_files.rs | 2 +- .../tests/in_process_gem_apply.rs | 8 +- .../tests/in_process_gem_fallback_home.rs | 4 +- .../tests/in_process_gem_multicopy.rs | 8 +- .../tests/in_process_get_update_count.rs | 11 +- .../tests/in_process_npm_multicopy.rs | 6 +- .../tests/in_process_pypi_apply.rs | 15 +- .../tests/in_process_redirect.rs | 17 +- .../tests/in_process_redirect_pipenv.rs | 4 +- .../tests/in_process_redirect_pnpm.rs | 5 +- .../in_process_remote_ecosystems_apply.rs | 10 +- .../in_process_remove_repair_lifecycle.rs | 6 +- .../in_process_rollback_all_ecosystems.rs | 32 +- .../tests/in_process_rollback_hosted.rs | 3 +- .../socket-patch-cli/tests/in_process_scan.rs | 49 +- .../tests/in_process_vendor.rs | 48 +- .../tests/in_process_vendor_bun.rs | 17 +- .../tests/in_process_vendor_bun_takeover.rs | 25 +- .../tests/interactive_prompts_e2e.rs | 16 +- .../tests/mode_migration_bun.rs | 5 +- .../tests/mode_migration_cargo.rs | 11 +- .../tests/mode_migration_npm.rs | 16 +- .../tests/output_helpers_e2e.rs | 2 +- .../tests/remove_duality_invariants.rs | 19 +- .../tests/remove_invariants.rs | 20 +- .../socket-patch-cli/tests/remove_network.rs | 13 +- .../tests/repair_invariants.rs | 14 +- .../tests/repair_vendor_e2e.rs | 4 +- .../tests/repair_vendor_flavors_e2e.rs | 11 +- .../tests/rollback_invariants.rs | 40 +- .../tests/rollback_multicopy_blob_gate.rs | 9 +- .../tests/scan_ecosystems_scope_e2e.rs | 12 +- .../socket-patch-cli/tests/scan_invariants.rs | 31 +- .../socket-patch-cli/tests/scan_paths_e2e.rs | 11 +- .../socket-patch-cli/tests/scan_sync_e2e.rs | 2 +- .../socket-patch-cli/tests/scan_vendor_e2e.rs | 25 +- .../socket-patch-cli/tests/self_update_e2e.rs | 6 +- .../tests/setup_contract_gaps.rs | 2 +- .../tests/setup_invariants.rs | 17 +- .../tests/setup_matrix_common/mod.rs | 16 +- .../tests/setup_matrix_composer.rs | 11 +- .../tests/setup_matrix_deno.rs | 5 +- .../tests/setup_matrix_gem.rs | 29 +- .../tests/setup_matrix_maven.rs | 18 +- .../tests/setup_matrix_npm.rs | 6 +- .../tests/setup_matrix_nuget.rs | 14 +- .../tests/setup_matrix_pypi.rs | 5 +- .../socket-patch-cli/tests/telemetry_e2e.rs | 31 +- .../tests/vendor_gem_lockfile_only_e2e.rs | 5 +- .../tests/vendor_partial_staging_e2e.rs | 12 +- .../tests/vendor_rerun_no_network_e2e.rs | 24 +- .../tests/vex_e2e_common/mod.rs | 4 +- .../tests/vex_e2e_common/uv.rs | 6 +- .../tests/vex_pdm_hatch_common/mod.rs | 8 +- .../tests/vex_pipenv_pip_common/mod.rs | 6 +- .../tests/vlt_e2e_common/fixture.rs | 6 +- .../tests/vlt_e2e_common/mod.rs | 8 +- crates/socket-patch-core/Cargo.toml | 3 +- .../socket-patch-core/src/api/blob_fetcher.rs | 3 +- crates/socket-patch-core/src/api/client.rs | 39 +- crates/socket-patch-core/src/api/date.rs | 3 +- crates/socket-patch-core/src/api/types.rs | 15 +- crates/socket-patch-core/src/constants.rs | 5 +- .../src/crawlers/cargo_crawler.rs | 2 +- .../src/crawlers/composer_crawler.rs | 17 +- .../src/crawlers/maven_crawler.rs | 2 +- .../src/crawlers/npm_crawler.rs | 29 +- .../src/crawlers/nuget_crawler.rs | 15 +- .../src/crawlers/python_crawler.rs | 40 +- .../src/crawlers/ruby_crawler.rs | 22 +- .../src/crawlers/walk_pool.rs | 36 +- crates/socket-patch-core/src/lib.rs | 2 +- .../src/manifest/cleanup_blobs.rs | 6 +- .../src/manifest/operations.rs | 2 +- .../socket-patch-core/src/manifest/schema.rs | 9 +- .../src/package_json/detect.rs | 17 +- .../src/package_json/find.rs | 43 +- .../src/package_json/update.rs | 2 +- crates/socket-patch-core/src/patch/apply.rs | 87 +- .../socket-patch-core/src/patch/apply_lock.rs | 31 +- .../socket-patch-core/src/patch/copy_tree.rs | 3 +- crates/socket-patch-core/src/patch/diff.rs | 23 +- crates/socket-patch-core/src/patch/mod.rs | 5 +- crates/socket-patch-core/src/patch/package.rs | 19 +- .../redirect/cargo_lock_equivalence_tests.rs | 2 +- .../redirect/composer_equivalence_tests.rs | 2 +- .../redirect/golang_equivalence_tests.rs | 2 +- .../src/patch/redirect/golang_local.rs | 3 +- .../patch/redirect/group_equivalence_tests.rs | 2 +- .../src/patch/redirect/mod.rs | 358 +++--- .../src/patch/redirect/npmrc.rs | 42 +- .../src/patch/redirect/pipenv.rs | 2 +- .../redirect/python_lock_equivalence_tests.rs | 2 +- .../src/patch/redirect/replay.rs | 17 +- .../src/patch/redirect/state.rs | 33 +- .../src/patch/redirect/takeover.rs | 70 +- .../src/patch/redirect/vlt.rs | 12 +- .../src/patch/redirect/vlt_heal.rs | 4 +- .../socket-patch-core/src/patch/rollback.rs | 116 +- .../src/patch/sidecars/cargo.rs | 73 +- .../src/patch/sidecars/mod.rs | 5 +- .../src/patch/sidecars/nuget.rs | 31 +- .../src/patch/sidecars/types.rs | 7 +- .../src/setup/composer/mod.rs | 8 +- crates/socket-patch-core/src/setup/gem/mod.rs | 2 +- crates/socket-patch-core/src/setup/mod.rs | 4 +- .../src/setup/pypi/detect.rs | 2 +- crates/socket-patch-core/src/telemetry.rs | 4 +- .../socket-patch-core/src/utils/concurrent.rs | 10 +- .../socket-patch-core/src/utils/durability.rs | 6 +- .../socket-patch-core/src/utils/env_compat.rs | 2 +- crates/socket-patch-core/src/utils/fs.rs | 11 +- .../src/utils/group_commit.rs | 11 +- crates/socket-patch-core/src/utils/mod.rs | 2 +- .../socket-patch-core/src/utils/pdm_lock.rs | 4 +- crates/socket-patch-core/src/utils/pipenv.rs | 7 +- .../src/utils/poetry_lock.rs | 10 +- crates/socket-patch-core/src/utils/process.rs | 12 +- crates/socket-patch-core/src/utils/purl.rs | 22 +- .../src/utils/requirements.rs | 2 +- .../socket-patch-core/src/utils/socket_dir.rs | 8 +- .../socket-patch-core/src/vendor/berry_zip.rs | 27 +- .../src/vendor/bun_binary.rs | 2 +- .../socket-patch-core/src/vendor/bun_lock.rs | 21 +- .../src/vendor/bun_lock_text.rs | 5 +- crates/socket-patch-core/src/vendor/cargo.rs | 60 +- .../src/vendor/cargo_config.rs | 20 +- .../src/vendor/cargo_lock.rs | 68 +- crates/socket-patch-core/src/vendor/common.rs | 21 +- .../src/vendor/composer_lock.rs | 28 +- crates/socket-patch-core/src/vendor/gem.rs | 132 +-- .../src/vendor/gemfile_lock.rs | 5 +- .../src/vendor/go_mod_edit.rs | 7 +- .../src/vendor/go_sum_edit.rs | 12 +- crates/socket-patch-core/src/vendor/golang.rs | 8 +- .../src/vendor/ledger_snapshots.rs | 4 +- .../src/vendor/lock_inventory/gem.rs | 1 - .../src/vendor/lock_inventory/npm_family.rs | 10 +- .../src/vendor/lock_inventory/pypi.rs | 21 +- .../lock_inventory/python_lock_union_tests.rs | 9 +- .../vendor/lock_inventory/recover_tests.rs | 12 +- .../src/vendor/lock_inventory/tests.rs | 32 +- .../src/vendor/lock_inventory/view.rs | 2 +- .../src/vendor/lock_inventory/vlt.rs | 2 +- .../src/vendor/maven_repo.rs | 12 +- crates/socket-patch-core/src/vendor/mod.rs | 49 +- .../src/vendor/npm_common.rs | 19 +- .../socket-patch-core/src/vendor/npm_dir.rs | 18 +- .../src/vendor/npm_flavor.rs | 40 +- .../socket-patch-core/src/vendor/npm_lock.rs | 53 +- .../socket-patch-core/src/vendor/npm_pack.rs | 2 +- .../src/vendor/nuget_feed.rs | 52 +- .../src/vendor/parse_memo.rs | 4 +- .../socket-patch-core/src/vendor/pnpm_lock.rs | 64 +- .../src/vendor/pnpm_lock_legacy.rs | 49 +- .../socket-patch-core/src/vendor/prestage.rs | 2 +- crates/socket-patch-core/src/vendor/pypi.rs | 139 +-- .../socket-patch-core/src/vendor/pypi_lock.rs | 28 +- .../socket-patch-core/src/vendor/pypi_pdm.rs | 43 +- .../src/vendor/pypi_pipenv.rs | 86 +- .../src/vendor/pypi_poetry.rs | 42 +- .../src/vendor/pypi_requirements.rs | 11 +- .../socket-patch-core/src/vendor/pypi_uv.rs | 92 +- .../src/vendor/pypi_wheel.rs | 15 +- .../src/vendor/registry_fetch.rs | 133 +-- crates/socket-patch-core/src/vendor/reuse.rs | 30 +- .../src/vendor/service_fetch.rs | 5 +- crates/socket-patch-core/src/vendor/source.rs | 27 +- crates/socket-patch-core/src/vendor/state.rs | 13 +- .../src/vendor/toml_surgery.rs | 19 +- crates/socket-patch-core/src/vendor/verify.rs | 22 +- .../socket-patch-core/src/vendor/vlt_lock.rs | 30 +- .../src/vendor/vlt_lock_text.rs | 5 +- .../src/vendor/yarn_berry_lock.rs | 75 +- .../src/vendor/yarn_classic_lock.rs | 44 +- .../src/vendor/yarn_layering_tests.rs | 30 +- crates/socket-patch-core/src/vex/build.rs | 15 +- .../src/vex/discover/cargo.rs | 5 +- .../src/vex/discover/deno.rs | 5 +- .../socket-patch-core/src/vex/discover/gem.rs | 9 +- .../src/vex/discover/maven.rs | 2 +- .../socket-patch-core/src/vex/discover/mod.rs | 16 +- .../src/vex/discover/testing/golden.rs | 3 +- .../socket-patch-core/src/vex/discover/vlt.rs | 2 +- crates/socket-patch-core/src/vex/product.rs | 33 +- crates/socket-patch-core/src/vex/schema.rs | 3 +- crates/socket-patch-core/src/vex/verify.rs | 12 +- .../socket-patch-core/tests/api_retry_e2e.rs | 2 +- .../binary_fetch_error_classification_e2e.rs | 17 +- .../tests/blob_fetcher_edges_e2e.rs | 2 +- .../tests/covgap_api_blob_fetcher.rs | 9 +- .../tests/covgap_crawlers_npm_crawler.rs | 4 +- .../tests/crawler_cargo_e2e.rs | 29 +- .../tests/crawler_composer_e2e.rs | 25 +- .../socket-patch-core/tests/crawler_go_e2e.rs | 9 +- .../tests/crawler_maven_e2e.rs | 22 +- .../tests/crawler_monorepo_gaps.rs | 2 +- .../tests/crawler_npm_e2e.rs | 167 ++- .../tests/crawler_nuget_e2e.rs | 20 +- .../tests/crawler_python_e2e.rs | 37 +- .../tests/crawler_ruby_e2e.rs | 10 +- crates/socket-patch-core/tests/diff_e2e.rs | 7 +- crates/socket-patch-core/tests/package_e2e.rs | 10 +- .../socket-patch-core/tests/poetry_hosted.rs | 7 +- .../tests/proxy_batch_e2e.rs | 13 +- .../tests/redirect_golden.rs | 2 +- gem/socket-patch/lib/socket_patch/launcher.rb | 4 +- .../src/schema/manifest-schema.ts | 2 +- pypi/socket-patch-hook/pyproject.toml | 9 +- pypi/socket-patch/pyproject.toml | 5 +- scripts/backtest-pipenv.py | 3 - scripts/fix-vuln.config.ts | 5 +- scripts/optimize-test-perf.config.ts | 10 +- scripts/study-crates.ts | 2 +- tests/setup_matrix/run-case.sh | 19 +- tests/setup_matrix/shims/npx | 6 +- tests/setup_matrix/shims/pnpm | 2 +- 363 files changed, 3957 insertions(+), 5414 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index fb6aa41c..e0acd1cd 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,6 +1,6 @@ # Windows main threads get a 1 MiB stack reserve by default (Unix mains get # 8 MiB). The CLI's async command futures poll deeply nested state machines -# — scan → download → in-process apply, or scan --vendor → the vendor engine +# — scan → download → in-process apply, or a vendored scan → the vendor engine # — and in debug builds (no stack-slot reuse) the summed poll frames exceed # 1 MiB, aborting with "thread 'main' has overflowed its stack" on Windows # only. Raise the PE stack reserve to the Unix default; spawned threads are diff --git a/.github/workflows/bun-compatibility.yml b/.github/workflows/bun-compatibility.yml index 94ee2941..d5982448 100644 --- a/.github/workflows/bun-compatibility.yml +++ b/.github/workflows/bun-compatibility.yml @@ -50,7 +50,7 @@ on: # (rust-cache keys on Cargo.lock, so Cargo.lock belongs here) and re-runs # the matrix post-merge on the code paths it exercises: the vendored engine # (`vendor/**` — bun_lock.rs, bun_lock_text.rs's shared version gate, - # npm_flavor.rs, lock_inventory.rs), the hosted rewriter + unwinds, and the + # npm_flavor.rs, lock_inventory/), the hosted rewriter + unwinds, and the # CLI drivers (`scan/**` — hosted.rs, vendor_flow.rs, mod.rs — plus the # vendor / repair / remove commands the matrix runs). push: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5ce8bba..4a260c8f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,10 +45,9 @@ jobs: # Swatinem/rust-cache instead of a raw actions/cache of the whole # target/ dir: it prunes the cache to dependency artifacts (~5-10x # smaller), which keeps this repo's total cache footprint inside - # GitHub's 10 GiB budget — previously a single main run produced - # ~8 GiB of caches and every PR save evicted them (cold e2e legs - # recompiled the full ~275-crate graph each run). save-if restricts - # writes to main so PR branches restore without churning the budget. + # GitHub's 10 GiB budget (a raw target/ cache let every PR save evict + # main's caches). save-if restricts writes to main so PR branches + # restore without churning the budget. uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: save-if: ${{ github.ref == 'refs/heads/main' }} @@ -253,13 +252,8 @@ jobs: run: rustup show - name: Cache cargo - # Swatinem/rust-cache instead of a raw actions/cache of the whole - # target/ dir: it prunes the cache to dependency artifacts (~5-10x - # smaller), which keeps this repo's total cache footprint inside - # GitHub's 10 GiB budget — previously a single main run produced - # ~8 GiB of caches and every PR save evicted them (cold e2e legs - # recompiled the full ~275-crate graph each run). save-if restricts - # writes to main so PR branches restore without churning the budget. + # Swatinem/rust-cache, main-only saves: see the first `Cache cargo` + # step in this file. uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: save-if: ${{ github.ref == 'refs/heads/main' }} @@ -292,13 +286,12 @@ jobs: # Retried: the install compiles sigstore/cosign, whose module # verification reads dozens of sum.golang.org checksum tiles, and # transient INTERNAL_ERROR stream resets there have failed this - # step on otherwise-green runs (2026-08-20: two attempts ~18 min - # apart, different modules each time — an upstream incident, not - # one bad tile). The backoff rides out short resets; a persistent - # outage still fails loudly on the last attempt. Follow-up option - # if this recurs: install the pinned release BINARY (sha256-pinned) - # instead of compiling, which sidesteps module verification and - # drops ~100s of compile time per leg. + # step on otherwise-green runs. The backoff rides out short + # resets; a persistent outage still fails loudly on the last + # attempt. Follow-up option if this recurs: install the pinned + # release BINARY (sha256-pinned) instead of compiling, which + # sidesteps module verification and drops ~100s of compile time per + # leg. shell: bash run: | for attempt in 1 2 3 4 5; do @@ -325,9 +318,7 @@ jobs: # # `--no-fail-fast`: without it cargo stops at the first failing test # BINARY, so one bad file hides every later binary's result on that - # OS (2026-09-03: two Windows-only fixture failures in - # covgap_commands_get masked ~180 binaries that had never run on - # Windows at all). Run them all and fail at the end instead. + # OS. Run them all and fail at the end instead. shell: bash env: # The real-go hosted/vendored suites (`#![cfg(unix)]`) ride the Go @@ -363,13 +354,8 @@ jobs: run: rustup show - name: Cache cargo - # Swatinem/rust-cache instead of a raw actions/cache of the whole - # target/ dir: it prunes the cache to dependency artifacts (~5-10x - # smaller), which keeps this repo's total cache footprint inside - # GitHub's 10 GiB budget — previously a single main run produced - # ~8 GiB of caches and every PR save evicted them (cold e2e legs - # recompiled the full ~275-crate graph each run). save-if restricts - # writes to main so PR branches restore without churning the budget. + # Swatinem/rust-cache, main-only saves: see the first `Cache cargo` + # step in this file. uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: save-if: ${{ github.ref == 'refs/heads/main' }} @@ -418,13 +404,8 @@ jobs: tool: cargo-llvm-cov@0.8.7 - name: Cache cargo - # Swatinem/rust-cache instead of a raw actions/cache of the whole - # target/ dir: it prunes the cache to dependency artifacts (~5-10x - # smaller), which keeps this repo's total cache footprint inside - # GitHub's 10 GiB budget — previously a single main run produced - # ~8 GiB of caches and every PR save evicted them (cold e2e legs - # recompiled the full ~275-crate graph each run). save-if restricts - # writes to main so PR branches restore without churning the budget. + # Swatinem/rust-cache, main-only saves: see the first `Cache cargo` + # step in this file. uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: save-if: ${{ github.ref == 'refs/heads/main' }} @@ -480,7 +461,7 @@ jobs: # # Hooks: docker_e2e_.rs reads SOCKET_PATCH_COV_BIN + # SOCKET_PATCH_COV_PROFRAW_DIR. Both unset is the no-op default - # (used by the e2e-docker matrix above). + # (used by the e2e-docker matrix below). # # Pin to ubuntu-22.04 (glibc 2.35) instead of ubuntu-latest # (currently 24.04, glibc 2.39). The instrumented binary built @@ -706,8 +687,8 @@ jobs: - os: ubuntu-latest suite: e2e_composer # composer is a shipped ecosystem, so e2e_composer's tests are - # NOT `#[ignore]`-gated the way the experimental-ecosystem suites - # (maven, nuget) are — the matrix default `--ignored` filter + # NOT `#[ignore]`-gated the way the live-registry maven/nuget + # suites are — the matrix default `--ignored` filter # selected zero tests here and the leg passed vacuously. # `--include-ignored` runs them, plus any capstone added later. test_filter: --include-ignored @@ -973,9 +954,9 @@ jobs: - {os: ubuntu-latest, suite: e2e_vlt, vlt: '1.0.7', test_filter: --include-ignored vlt_pinned_matrix} - {os: windows-latest, suite: e2e_vlt, vlt: '1.0.0-rc.14', test_filter: --include-ignored vlt_pinned_matrix} # The named corepack pnpm hosted legs (pnpm 7-11, get-uuid, - # zero-touch, --trust-lockfile). `#[ignore]`d and previously run in - # no job; the pinned matrix inside the same suite runs in - # pnpm-compatibility.yml, hence the skip. Node 24 (step below): the + # zero-touch, --trust-lockfile). `#[ignore]`d; the pinned matrix + # inside the same suite runs in pnpm-compatibility.yml, hence the + # skip. Node 24 (step below): the # corepack pnpm@10/11 legs require it. - {os: ubuntu-latest, suite: e2e_redirect_pnpm_build, test_filter: '--ignored --skip pnpm_pinned_matrix'} - {os: macos-latest, suite: e2e_redirect_pnpm_build, test_filter: '--ignored --skip pnpm_pinned_matrix'} @@ -1104,13 +1085,8 @@ jobs: run: rustup show - name: Cache cargo - # Swatinem/rust-cache instead of a raw actions/cache of the whole - # target/ dir: it prunes the cache to dependency artifacts (~5-10x - # smaller), which keeps this repo's total cache footprint inside - # GitHub's 10 GiB budget — previously a single main run produced - # ~8 GiB of caches and every PR save evicted them (cold e2e legs - # recompiled the full ~275-crate graph each run). save-if restricts - # writes to main so PR branches restore without churning the budget. + # Swatinem/rust-cache, main-only saves: see the first `Cache cargo` + # step in this file. uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: # Matrix suites otherwise collide on one key: only one of the ~9 @@ -1407,8 +1383,9 @@ jobs: # managers and run socket-patch against a wiremock-served fixture — # no real Socket API contact. Hermetic, reproducible. # - # Triggered on every PR. The existing `e2e` job above stays for - # `--ignored` real-API smoke runs (manual / scheduled). + # Triggered on every PR. The `e2e` job above runs the + # `#[ignore]`-gated real-toolchain capstones; the live-API smoke suites + # are run by hand (see the note in its matrix). # ---------------------------------------------------------------------- e2e-docker: runs-on: ubuntu-latest @@ -1459,9 +1436,10 @@ jobs: - name: Run ${{ matrix.ecosystem }} Docker e2e test # Every ecosystem is unconditionally compiled in; only the # `docker-e2e` feature is needed to compile the suite itself. - # The vendored build-proof capstones (each ending in the - # manifest-less VEX stage) ride the same image as their ecosystem's - # main suite, as in coverage-docker. + # The composer/nuget/pypi vendored build-proof capstones (each + # ending in the manifest-less VEX stage) ride the same image as + # their ecosystem's main suite; the gem and maven vendor capstones + # run only in coverage-docker. run: | EXTRA="" case "${{ matrix.ecosystem }}" in @@ -1644,9 +1622,9 @@ jobs: # tests/setup_matrix/ and scripts/setup-matrix.sh. # # This is EXPERIMENTAL and intentionally not required to pass yet: - # `setup` only configures npm-family install hooks today, so most - # non-npm `baseline_with_setup` cases are EXPECTED to fail (a baseline - # of what `setup` must eventually support). `continue-on-error: true` + # `setup` configures install hooks for npm, PyPI, Bundler and Composer + # only, so the other ecosystems' `baseline_with_setup` cases are + # EXPECTED to fail (a baseline of what `setup` must eventually support). `continue-on-error: true` # means this job never blocks a PR — it must ALSO be left OUT of the # repo's required status checks (configured in the branch-protection # UI, not in this file). The orchestrator exits non-zero only on a diff --git a/.github/workflows/pdm-compatibility.yml b/.github/workflows/pdm-compatibility.yml index dc7ecc14..ef4401b6 100644 --- a/.github/workflows/pdm-compatibility.yml +++ b/.github/workflows/pdm-compatibility.yml @@ -16,7 +16,7 @@ on: - 'crates/socket-patch-core/src/patch/redirect/**' - 'crates/socket-patch-core/src/vendor/pypi*.rs' - 'crates/socket-patch-core/src/vendor/common.rs' - - 'crates/socket-patch-core/src/vendor/lock_inventory.rs' + - 'crates/socket-patch-core/src/vendor/lock_inventory/**' - 'crates/socket-patch-cli/src/commands/scan/**' - 'crates/socket-patch-cli/src/commands/rollback.rs' - 'crates/socket-patch-core/tests/fixtures/pdm-native/**' diff --git a/.github/workflows/pipenv-compatibility.yml b/.github/workflows/pipenv-compatibility.yml index 6d7d9c90..a24d9bd2 100644 --- a/.github/workflows/pipenv-compatibility.yml +++ b/.github/workflows/pipenv-compatibility.yml @@ -14,7 +14,7 @@ on: - 'crates/socket-patch-core/src/patch/redirect/pipenv.rs' - 'crates/socket-patch-core/src/vendor/pypi_pipenv.rs' - 'crates/socket-patch-core/src/vendor/pypi.rs' - - 'crates/socket-patch-core/src/vendor/lock_inventory.rs' + - 'crates/socket-patch-core/src/vendor/lock_inventory/**' - 'crates/socket-patch-core/src/crawlers/python_crawler.rs' - 'crates/socket-patch-core/src/utils/pipenv.rs' - 'crates/socket-patch-cli/src/commands/scan/hosted.rs' diff --git a/.github/workflows/publish-rubygems.yml b/.github/workflows/publish-rubygems.yml index a8e48b3d..01238d0f 100644 --- a/.github/workflows/publish-rubygems.yml +++ b/.github/workflows/publish-rubygems.yml @@ -144,8 +144,8 @@ jobs: # Phase 2 scaffolding (CLI_CONTRACT "gem" support matrix): publish the # `socket-patch-bundler` gem — the published form of the Bundler plugin - # that `socket-patch setup` currently wires via an in-tree `git:` - # reference. This gem is NOT yet the active mechanism (setup::gem still + # that `socket-patch setup` currently wires through an in-tree + # `plugin ..., path:` directive. This gem is NOT yet the active mechanism (setup::gem still # emits the in-tree plugin), so the push is **non-blocking** # (`continue-on-error`). A follow-up switches the generated Gemfile # directive to `plugin "socket-patch-bundler"` and drops diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7c2abcb4..3336c096 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,8 +31,7 @@ name: Release # registration per package covers everything, on every registry. # 2. GitHub suppresses events caused by this workflow's own GITHUB_TOKEN — # a `release: published` (or `push: tags:`) trigger in another file -# would never fire, which is why the old design kept every publish job -# in this file — but workflow_dispatch (and repository_dispatch) events +# would never fire — but workflow_dispatch (and repository_dispatch) events # are documented exceptions, so `gh workflow run` with GITHUB_TOKEN # works without a PAT/GitHub App token. # diff --git a/.gitignore b/.gitignore index b3a6faf3..d7c6d412 100644 --- a/.gitignore +++ b/.gitignore @@ -145,11 +145,6 @@ vite.config.ts.timestamp-* # Rust target/ -# Maven / NuGet launcher build output -maven/socket-patch/target/ -nuget/socket-patch/bin/ -nuget/socket-patch/obj/ - # npm binaries (populated at publish time) npm/socket-patch/bin/socket-patch-* diff --git a/Cargo.toml b/Cargo.toml index b985c700..6a95c787 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,7 +67,7 @@ opt-level = "s" # CI-only profile for the test-release job. Inherits the shipped profile's # semantics (opt-level = "s", debug-assertions off, overflow-checks off) — -# which is what test-release exists to validate (commit b96a13f) — but drops +# which is what test-release exists to validate — but drops # the full-LTO link: ~23m of that job's ~29m was LTO-relinking 159 test # binaries, and LTO relinks are structurally uncacheable. release.yml still # builds the real full-LTO [profile.release] for every shipped target. @@ -84,9 +84,8 @@ lto = "thin" # Test-execution speed: `cargo test` builds dependencies with the dev # profile; at opt-level 0 the hash/compression/bsdiff hot loops are 10-100x -# slower (the self_update fixture family measured 403s debug vs ~2s release -# on CI run 30289993021 — it gzips and sha256-hashes the multi-MB debug CLI -# binary per test). Workspace members are NOT matched by "*" — they stay +# slower (the self_update fixtures gzip and sha256-hash the multi-MB debug +# CLI binary per test: ~403s debug vs ~2s release). Workspace members are NOT matched by "*" — they stay # opt-level 0, so incremental compile speed, debugging, and llvm-cov line # fidelity (reports filter to workspace crates) are unchanged. Dependencies # keep debug-assertions and overflow-checks ON — only codegen opt changes. diff --git a/crates/socket-patch-cli/Cargo.toml b/crates/socket-patch-cli/Cargo.toml index 9d9166e2..4858d339 100644 --- a/crates/socket-patch-cli/Cargo.toml +++ b/crates/socket-patch-cli/Cargo.toml @@ -45,8 +45,7 @@ windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_System_ [features] # Every ecosystem (npm, PyPI, Ruby gems, Go, Cargo, NuGet, Maven, Composer, # Deno) is unconditionally compiled in AND enabled at runtime — there are no -# ecosystem feature gates and no runtime env gates (the old -# SOCKET_EXPERIMENTAL_MAVEN/NUGET opt-ins are retired). +# ecosystem feature gates and no runtime env gates. # The only features left gate opt-in test suites: # # Enables the Docker-driven real-package e2e test suite under diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index f8820a9b..8ada7e0c 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -886,8 +886,8 @@ mod tests { /// `scrub_empty_env_vars` removes exactly-empty `SOCKET_*` flag vars /// (the `VAR=` blank-without-unsetting idiom) — global and local — and /// nothing else: set, non-empty values — even whitespace-only ones, - /// which are significant in paths — survive, and the - /// previously-crashing parse then sees plain defaults. + /// which are significant in paths — survive, and the parse then sees + /// plain defaults. #[test] #[serial_test::serial] fn scrub_empty_env_vars_unsets_only_empties() { @@ -983,8 +983,7 @@ mod tests { } /// Regression: scan's vendored flow must build its service config FROM - /// `--vendor-source`, not hardcode build-only (the pre-fix `service = - /// None`). Under the default (`auto`), the config must permit the + /// `--vendor-source`, not hardcode build-only. Under the default (`auto`), the config must permit the /// vendoring service exactly as the `vendor` command's default does — /// otherwise `scan --mode vendored` silently builds locally while a /// plain `vendor` service-downloads, and the two commit different bytes / @@ -1105,7 +1104,7 @@ mod tests { } } - /// The bug fix: an empty (or whitespace-only) string is `false`, not an + /// An empty (or whitespace-only) string is `false`, not an /// error. Shells/CI export `SOCKET_OFFLINE=` to mean "unset". #[test] fn parse_bool_flag_treats_empty_as_false() { @@ -1122,9 +1121,9 @@ mod tests { assert!(parse_bool_flag("tru").is_err()); } - /// Regression: an exported-but-empty bool env var must NOT crash the parse. - /// Before the fix, `BoolishValueParser` aborted with "value was not a - /// boolean", taking down every subcommand. Now it resolves to `false`. + /// Regression: an exported-but-empty bool env var must NOT crash the parse + /// (`BoolishValueParser` rejects it, taking down every subcommand); it + /// resolves to `false`. #[test] #[serial_test::serial] fn empty_bool_env_var_parses_as_false_not_crash() { diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 0e14f686..b1b932eb 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -338,7 +338,7 @@ pub struct ApplyArgs { #[command(flatten)] pub vex: VexEmbedArgs, - /// Set when `get` / `scan --apply/--sync` runs this apply as its last + /// Set when `get` / `scan --mode agent` runs this apply as its last /// step (`None` for the `apply` command itself). Not a CLI flag. #[arg(skip)] pub nested: Option, @@ -363,8 +363,7 @@ impl ApplyArgs { } // ── local-go redirect helpers ──────────────────────────────────────────────── -// The Go analog of the cargo helpers above: in local mode a `pkg:golang/…` PURL -// redirects to a project-local patched copy under `.socket/go-patches/` wired via +// In local mode a `pkg:golang/…` PURL redirects to a project-local patched copy under `.socket/go-patches/` wired via // a `go.mod` `replace` directive. /// True for a golang PURL in local mode (no `--global` / `--global-prefix`). @@ -738,9 +737,8 @@ fn manifest_targets_npm(manifest: &PatchManifest) -> bool { /// Print the yarn-PnP refusal (JSON envelope or human stderr) and return /// apply's refusal exit code. Shared by the pre-manifest gate and the /// package-manager layout gate below: scan cannot discover PnP packages so -/// it never writes a manifest, which used to leave the calm `noManifest` -/// exit as the ONLY thing a PnP user ever saw — the documented loud -/// `yarn_pnp_unsupported` refusal was unreachable without a manifest. +/// it never writes a manifest, and the loud `yarn_pnp_unsupported` refusal +/// must still be reachable without one. fn refuse_yarn_pnp(args: &ApplyArgs) -> i32 { if args.common.json { let mut env = Envelope::new(Command::Apply); @@ -909,7 +907,7 @@ pub async fn run(args: ApplyArgs) -> i32 { /// package-manager layout gate, the apply loop, embedded VEX, output and /// telemetry — over a `lock` the caller already holds and the caller's /// `client`. [`run`] takes the lock itself; agent-mode `get` and -/// `scan --apply/--sync` call this straight after their manifest write, so +/// `scan --mode agent` call this straight after their manifest write, so /// download → manifest write → apply is ONE lock window (a same-process /// re-acquire would contend) and the nested apply never builds a second /// client. `lock` is released explicitly once every mutation is done @@ -1353,9 +1351,11 @@ struct ApplyOutcome { results: Vec, /// In-scope manifest purls with no installed package on disk. unmatched: Vec, - /// Run-level advisories: JSON `warnings[]`, one gated stderr line each - /// on the human path (`--silent` = errors only). Today: the gem - /// config-root containment skip. + /// Run-level advisories: JSON `warnings[]`, and one gated stderr line + /// each on the human path (`--silent` = errors only) except the + /// sources-unavailable codes (already printed by the stager): the gem + /// config-root containment skip and the sources-unavailable reason + /// (`run` adds `ownership_not_restored`). run_warnings: Vec, /// Gem-env fallback-home copies deliberately left unpatched /// (best-effort class): one non-fatal `Skipped` event each in the @@ -1687,10 +1687,9 @@ async fn apply_patches_inner( if partitioned.is_empty() { // Nothing in scope: the manifest lists no patches (or every patch was // filtered out by `--ecosystems`). There is genuinely no work to do, - // so this is a clean no-op SUCCESS — not a failure. Returning `false` - // here used to exit 1 / `partialFailure`, which broke the npm - // `postinstall` hook (it runs `apply` on every install, including - // fresh projects whose manifest has no matching patches yet). Decided + // so this is a clean no-op SUCCESS — not a failure: the npm + // `postinstall` hook runs `apply` on every install, including fresh + // projects whose manifest has no matching patches yet. Decided // BEFORE the ledger read, gem discovery and the crawl — none of which // can add work to an empty scope — but AFTER the staging above, which // is where `--download-mode` is validated at runtime. @@ -1787,8 +1786,8 @@ async fn apply_patches_inner( let mut unmatched = unmatched; unmatched.sort(); // This diagnostic flips the exit code, so it is an error — and it - // prints even under --silent ("errors only", never nothing — the - // hooked `apply --silent` used to exit 1 mutely here); `--json` + // prints even under --silent ("errors only", never a mute exit 1); + // `--json` // mutes stderr and the envelope's `package_not_installed` events // are the channel. if !unmatched.is_empty() && args.prints_errors() { @@ -2098,7 +2097,7 @@ async fn apply_patches_inner( if is_vendored(purl) { continue; } - // npm PURLs: direct lookup + // Non-variant PURLs: direct lookup let patch = match manifest.patches.get(purl) { Some(p) => p, None => continue, @@ -2115,8 +2114,8 @@ async fn apply_patches_inner( // Local go redirects to a project-local patched copy under // `.socket/go-patches/` wired via a `go.mod` `replace` (the // module cache is `go.sum`-verified, so in-place patching - // can't build). Everything else — npm/pypi/gem and cargo - // (vendored or registry cache) — patches in place via + // can't build). Everything else in this branch (npm, cargo, + // composer, nuget, …) patches in place via // `apply_package_patch`. let result = match try_local_go_apply(purl, pkg_path, patch, &sources, &args.common, policy) @@ -2422,10 +2421,8 @@ mod tests { /// Regression: a non-installed release variant whose first patched /// file is `NotFound` (e.g. an sdist patching `setup.py` while only a /// wheel is on disk) must be treated as NOT installed and skipped — - /// exactly like a `HashMismatch`. Before the fix the loop only skipped - /// `HashMismatch`, so a `NotFound` variant slipped through to - /// `apply_package_patch` and produced a spurious `Failed` event in the - /// JSON envelope. This pins the apply-side decision to the same + /// exactly like a `HashMismatch`, never reaching `apply_package_patch` + /// as a spurious `Failed` event. This pins the apply-side decision to the same /// Ready/AlreadyPatched contract as `select_installed_variants`. #[test] fn variant_matches_only_when_first_file_ready_or_already_patched() { diff --git a/crates/socket-patch-cli/src/commands/fetch_stage.rs b/crates/socket-patch-cli/src/commands/fetch_stage.rs index c0ac01ec..e60bddb9 100644 --- a/crates/socket-patch-cli/src/commands/fetch_stage.rs +++ b/crates/socket-patch-cli/src/commands/fetch_stage.rs @@ -175,11 +175,6 @@ fn format_fetch_failures(result: &FetchMissingBlobsResult, (one, many): Noun) -> lines } -/// Announce the per-file blob top-up that follows a diff-mode fetch. It -/// runs even when every diff archive arrived — a diff cannot patch a file -/// whose bytes differ from `beforeHash`, and the pipeline then falls back -/// to the blob — so it is worded as a complement, not a failure, unless -/// some archives really were unavailable. /// The disk stager's status line while it downloads what `.socket/` lacks. const DOWNLOADING_ARTIFACTS: &str = "Downloading missing patch artifacts..."; @@ -188,6 +183,11 @@ fn format_fetching_content(n: usize) -> String { format!("Fetching content for {}...", plural(n, "patch", "patches")) } +/// Announce the per-file blob top-up that follows a diff-mode fetch. It +/// runs even when every diff archive arrived — a diff cannot patch a file +/// whose bytes differ from `beforeHash`, and the pipeline then falls back +/// to the blob — so it is worded as a complement, not a failure, unless +/// some archives really were unavailable. fn format_blob_fallback(diff_failed: usize, blobs: usize) -> String { let blobs = plural(blobs, "per-file blob", "per-file blobs"); if diff_failed == 0 { @@ -311,8 +311,7 @@ pub(crate) async fn stage_patch_sources( // locally. We honor `--download-mode` for the primary fetch when there's // actually a gap to close. Skip the archive fetch entirely when all file // blobs are already present locally — the pipeline will succeed via the - // blob path, and the archive endpoints would just 404 (current server - // doesn't serve them yet). + // blob path, so an archive fetch would be wasted round-trips. let download_needed = !common.offline && match download_mode { DownloadMode::File => !missing_blobs.is_empty(), @@ -1127,9 +1126,7 @@ mod tests { /// A local package archive is a usable source (the pipeline's Strategy 1, /// and exactly what the offline gate rules), so an online run whose /// downloads all fail must still be Ready when the package archive covers - /// every patch. Regression: the failure gate used aggregate fetch - /// counters and never consulted package archives, so this cache state was - /// Unavailable online while succeeding with --offline. + /// every patch, exactly as it succeeds with --offline. #[tokio::test] async fn stage_online_fetch_failure_accepts_local_package_archive() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-cli/src/commands/hosted_bundle.rs b/crates/socket-patch-cli/src/commands/hosted_bundle.rs index 67fe2bb1..392ac771 100644 --- a/crates/socket-patch-cli/src/commands/hosted_bundle.rs +++ b/crates/socket-patch-cli/src/commands/hosted_bundle.rs @@ -11,7 +11,8 @@ //! "presentOnly"?: [path], "symlinks"?: [path], "projectRoots"?: [dir], //! "pipenvMajor"?: n, "batchSize"?: n}`. Stdout: the engine result //! (`HostedScanResult`, binary contents base64), or -//! `{"status":"error","error":{"code","message"}}` with exit 1. +//! `{"status":"error","error":{"code","message"}}` with exit 2 for bad +//! credentials/bundle input, or exit 1 for an engine failure. use std::collections::BTreeMap; use std::io::Read; diff --git a/crates/socket-patch-cli/src/commands/list.rs b/crates/socket-patch-cli/src/commands/list.rs index 65ab4bc5..6bbc5e76 100644 --- a/crates/socket-patch-cli/src/commands/list.rs +++ b/crates/socket-patch-cli/src/commands/list.rs @@ -28,9 +28,7 @@ enum Source { Manifest, /// A hosted redirect-ledger record: `scan --mode hosted` records its /// patches ONLY in `.socket/vendor/redirect-state.json` and never - /// writes the manifest — without these, a purely hosted-wired project - /// listed as `manifest_not_found` while its patches were demonstrably - /// live. + /// writes the manifest. Hosted, /// A vendor-ledger record: vendored mode is manifest-free, so every /// `scan`/`get --mode vendored` patch lives ONLY in @@ -120,14 +118,8 @@ fn combined_entries<'a>( /// /// Events are emitted in the entries' given order — [`combined_entries`] /// owns the by-PURL event sort; this builder sorts each event's -/// vulnerabilities (by advisory ID) and files (by path). `HashMap` -/// iteration is otherwise nondeterministic, so without these sorts the -/// vuln/file ordering would change run-to-run — breaking consumers that -/// diff this output in CI logs. Mirrors the stable-ordering guarantee -/// `get` already provides for its vulnerability lists. -/// -/// Shared by `run` and the unit tests so the tests exercise the exact code -/// path `list --json` uses, rather than a hand-copied duplicate. +/// vulnerabilities (by advisory ID) and files (by path) so the output is +/// stable across runs (`HashMap` iteration is not). fn build_list_envelope(entries: &[ListEntry<'_>]) -> Envelope { let mut env = Envelope::new(Command::List); @@ -253,11 +245,7 @@ fn format_entry(entry: &ListEntry<'_>, color: bool) -> String { let mut lines = vec![format!("Package: {}", sanitize(entry.purl))]; lines.extend(field(" ", "UUID", &patch.uuid)); if let Some((mode, ledger)) = ledger_label(entry.source) { - // Same labeling rule as the JSON details: the record comes from a - // ledger, not the manifest — hosted installs resolve the package - // to the hosted patch server, vendored ones to the committed - // `.socket/vendor/` artifact; no manifest entry exists or is - // needed. + // Same labeling rule as the JSON details. lines.push(format!(" Mode: {mode} (recorded in {ledger})")); } lines.extend(field(" ", "Tier", &patch.tier)); @@ -265,7 +253,6 @@ fn format_entry(entry: &ListEntry<'_>, color: bool) -> String { lines.extend(field(" ", "Exported", &patch.exported_at)); lines.extend(field(" ", "Description", &patch.description)); - // Sort vulnerabilities by advisory ID for stable output. let mut vuln_entries: Vec<_> = patch.vulnerabilities.iter().collect(); vuln_entries.sort_by(|a, b| a.0.cmp(b.0)); if !vuln_entries.is_empty() { @@ -290,7 +277,6 @@ fn format_entry(entry: &ListEntry<'_>, color: bool) -> String { } } - // Sort patched files by path for stable output. let mut file_list: Vec<_> = patch.files.keys().collect(); file_list.sort(); if !file_list.is_empty() { @@ -323,23 +309,16 @@ pub async fn run(args: ListArgs) -> i32 { // `read_manifest` is the single source of truth for the three error // states: `Ok(None)` (file absent), `Err(InvalidData)` (present but - // unparseable), and any other `Err` (genuine I/O failure). We deliberately - // do NOT stat the path first: a `metadata` pre-check is both redundant and - // wrong — it reports *any* stat failure (e.g. an unreadable parent dir) as - // `manifest_not_found`, masking real I/O errors that owe a - // `manifest_unreadable`, and it opens a TOCTOU window where a file removed - // between the stat and the read lands in the wrong error arm. + // unparseable), and any other `Err` (genuine I/O failure). No stat + // pre-check: it would report any stat failure as `manifest_not_found` + // and open a TOCTOU window. let manifest = match read_manifest(&manifest_path).await { Ok(manifest) => manifest, Err(e) => { - // A manifest that exists but is unparseable (bad JSON or a - // schema violation) surfaces as `ErrorKind::InvalidData` — the - // contract's `manifest_invalid`. Everything else is a genuine - // I/O failure (`manifest_unreadable`). Conflating the two would - // tell a consumer to retry on a corrupt file, or to give up on a - // transient I/O error. See CLI_CONTRACT.md error-code table. - // Hosted-ledger records never mask either: a present-but-broken - // manifest is an error state, not a hosted-only project. + // `InvalidData` (bad JSON or schema) is the contract's + // `manifest_invalid`; everything else is `manifest_unreadable` + // (see CLI_CONTRACT.md error-code table). Ledger records never + // mask either: a present-but-broken manifest is an error state. let code = if e.kind() == std::io::ErrorKind::InvalidData { "manifest_invalid" } else { @@ -398,12 +377,7 @@ pub async fn run(args: ListArgs) -> i32 { vendor_state.as_ref().map(|s| &s.entries), ); if manifest.is_none() && entries.is_empty() { - // No manifest AND no ledger records: nothing is listable anywhere — - // the classic missing-manifest error. `read_manifest` returns - // `Ok(None)` only when the file does not exist (its documented - // contract), so this is `manifest_not_found`, NOT `manifest_invalid` - // (which means the file is present but corrupt). See CLI_CONTRACT.md - // error-code table. + // No manifest AND no ledger records: nothing is listable anywhere. emit_error( &args, "manifest_not_found", @@ -413,15 +387,11 @@ pub async fn run(args: ListArgs) -> i32 { return 1; } - // Records found (either store) ⇒ a successful list, exit 0 — including - // the purely hosted-wired project that used to hard-fail here. + // Records found (any store) ⇒ a successful list, exit 0. // - // Telemetry: `patch_listed`'s `patches_count` predates the hosted - // folding and its consumers read it as "manifest patches", so it keeps - // counting the manifest ONLY (0 on a hosted-only project) — folding the - // listed entries in would silently redefine the metric and double-count - // purls present in both stores. Hosted visibility, if wanted, belongs - // in a new dedicated field. + // Telemetry: `patch_listed`'s `patches_count` means "manifest patches" + // to its consumers, so it counts the manifest ONLY (0 on a ledger-only + // project) rather than the listed entries. let manifest_patch_count = manifest.as_ref().map_or(0, |m| m.patches.len()); let (api_token, org_slug) = args.common.telemetry_credentials(); track_patch_listed( @@ -436,9 +406,7 @@ pub async fn run(args: ListArgs) -> i32 { env.warnings = warnings; println!("{}", env.to_pretty_json()); } else if args.common.silent { - // `--silent` is "errors only" (CLI_CONTRACT.md): suppress the - // entire human-readable listing, mirroring `get`/`repair`. - // The exit code still distinguishes the manifest states. + // `--silent` is "errors only" (CLI_CONTRACT.md). } else { println!("{}", format_listing(&entries, crate::ui::stdout_color())); } @@ -448,8 +416,8 @@ pub async fn run(args: ListArgs) -> i32 { #[cfg(test)] mod tests { - //! Inline tests for `list` JSON output. Pin the new envelope shape - //! so downstream consumers (PR bots, dashboards) can rely on it. + //! Inline tests for `list` output. Pin the envelope shape so downstream + //! consumers (PR bots, dashboards) can rely on it. use super::*; use socket_patch_core::manifest::schema::{PatchFileInfo, PatchRecord, VulnerabilityInfo}; use std::collections::HashMap; @@ -601,11 +569,9 @@ mod tests { assert_eq!(v["summary"]["discovered"], 0); } - // -- Regression: stable ordering ------------------------------------- - // `HashMap` iteration order is randomized per run, so without explicit - // sorting the events / vulnerabilities / files arrays would shuffle - // between invocations. These pin the sorted contract so consumers can - // diff `list --json` output in CI logs. + // -- Stable ordering ------------------------------------------------- + // Pin the sorted events / vulnerabilities / files contract so consumers + // can diff `list --json` output. #[test] fn events_are_sorted_by_purl() { diff --git a/crates/socket-patch-cli/src/commands/lock_cli.rs b/crates/socket-patch-cli/src/commands/lock_cli.rs index 6b804c07..3e9d01b0 100644 --- a/crates/socket-patch-cli/src/commands/lock_cli.rs +++ b/crates/socket-patch-cli/src/commands/lock_cli.rs @@ -3,8 +3,8 @@ //! //! Mutating subcommands (`apply`, `rollback`, `repair`, `remove`, //! `vendor`) all need the same shape: acquire the lock at the top of -//! `run`, on contention emit a JSON envelope with `errorCode: -//! "lock_held"` (or stderr in human mode) and exit 1. This module +//! `run`, on contention emit a JSON envelope with `error.code: +//! "lock_held"` (status "error") (or stderr in human mode) and exit 1. This module //! centralises that emission so the call sites stay one line each. //! //! The lock itself is in `socket-patch-core` (cross-crate, also used @@ -337,13 +337,12 @@ mod tests { /// Regression guard carried over from the `--break-lock` era: the /// wrapper must never open a window in which a competitor can be - /// robbed of a lock it legitimately acquired. The historical buggy - /// shape probed, then `remove_file`d the lock file, then - /// re-acquired: a competitor that flocked (or had merely *opened*) - /// the file before the unlink kept a valid lock on the orphaned - /// inode while the re-acquire locked a fresh one — two live holders - /// at once. Today every guard drop unlinks the file, so this is the - /// live stress test of the core protocol that makes that safe: + /// robbed of a lock it legitimately acquired: a competitor that + /// flocked (or had merely *opened*) the file before an unlink keeps a + /// valid lock on the orphaned inode while a re-acquire locks a fresh + /// one — two live holders at once. Every guard drop unlinks the file, + /// so this is the live stress test of the core protocol that makes + /// that safe: /// unlink WHILE holding the lock, and re-check the locked handle's /// identity against the path after every successful lock. /// @@ -566,8 +565,7 @@ mod tests { ); } - /// The `--json` failure envelope (previously emitted only via - /// `println!`, so untested) has the stable error shape downstream + /// The `--json` failure envelope has the stable error shape downstream /// consumers pattern-match on: top-level `status: "error"` and /// `error.code` carrying the lock reason tag. #[test] diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index f19d47d4..ddda23e2 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -69,7 +69,9 @@ pub(crate) async fn discover_wiring( /// from `load_redirect_state`'s contract — the warning is advisory /// (muted by `--silent`, "errors only"), because every path that would /// WRITE or ATTEST from the ledger hard-errors on the same corruption -/// instead. Shared by both of scan's read-only consults. +/// instead. Used by scan's empty-discovery `redirectState` consult; the +/// main-path consult inlines the same posture so it can flush telemetry +/// before the warning. pub(crate) async fn load_redirect_state_lenient( cwd: &Path, silent: bool, diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index 82e5f018..9ce95401 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -692,8 +692,9 @@ pub async fn run(args: RemoveArgs) -> i32 { // intact (mirroring the `rollback_failed` contract). A corrupt ledger // is a hard error: we are about to mutate and cannot know what we // would leave wired. `--skip-rollback` ("don't touch my tree") skips - // the revert too — the wiring stays until the next `vendor` run - // reconciles the then-dropped entry. + // the revert too — the wiring stays until `vendor --revert` / a later + // `remove` undoes it (a manifest-tracked entry is also reconcile-reverted + // by the next `vendor` run; detached entries never are). let mut vendor_state = match vendor_state_result { Ok(s) => s, Err(e) => { @@ -745,7 +746,7 @@ pub async fn run(args: RemoveArgs) -> i32 { // ── hosted leg ────────────────────────────────────────────────────── // An identifier can also (or only) match hosted records in the - // redirect ledger. Supported ecosystems (cargo, npm-family) unwind + // redirect ledger. Supported ecosystems (cargo, npm-family, golang) unwind // per-purl; when the identifier covers EVERY record the whole-ledger // replay serves the rest; otherwise unsupported targets fail closed // BEFORE the manifest mutation. A corrupt ledger skips the leg with a diff --git a/crates/socket-patch-cli/src/commands/repair.rs b/crates/socket-patch-cli/src/commands/repair.rs index 58d0699b..4fb8ed4d 100644 --- a/crates/socket-patch-cli/src/commands/repair.rs +++ b/crates/socket-patch-cli/src/commands/repair.rs @@ -28,10 +28,9 @@ pub struct RepairArgs { // // `value_parser = parse_bool_flag` matches the `GlobalArgs` bool flags: // clap's default bool parser accepts only the literal strings - // `true`/`false` from the env binding, so `SOCKET_DOWNLOAD_ONLY=1` (or - // an exported-but-empty `SOCKET_DOWNLOAD_ONLY=`) aborted every `repair` - // invocation. This flag is also outside `GLOBAL_ARG_ENV_VARS`, so - // `main`'s empty-var scrub never rescues it. + // `true`/`false` from the env binding, so `SOCKET_DOWNLOAD_ONLY=1` would + // abort every `repair` invocation. (`main`'s empty-var scrub covers an + // exported-but-empty value via `LOCAL_ARG_ENV_VARS`.) #[arg( long = "download-only", env = "SOCKET_DOWNLOAD_ONLY", @@ -401,7 +400,7 @@ async fn repair_inner( // Step 1: Check for and download missing artifacts in the requested // mode. Counts below refer to whatever kind of artifact was requested - // (file blobs, diff archives, or package archives). + // (file blobs or diff archives). // // VENDORED-in-sync manifest entries are excluded: vendor flows keep // patch content in memory and the committed artifact IS the patch, so @@ -785,9 +784,8 @@ mod tests { /// Regression for the offline + dry-run leak: with `--offline` set, the /// download phase is skipped entirely, so even in dry-run mode a missing - /// artifact must NOT produce a "would-download" (verified) event. Before - /// the fix the event was recorded unconditionally on `dry_run && - /// missing > 0`, contradicting the human-readable path (which only warns). + /// artifact must NOT produce a "would-download" (verified) event, + /// matching the human-readable path (which only warns). #[tokio::test] async fn offline_dry_run_does_not_record_download_event() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-cli/src/commands/repair_vendor.rs b/crates/socket-patch-cli/src/commands/repair_vendor.rs index 52f0b331..77df1be2 100644 --- a/crates/socket-patch-cli/src/commands/repair_vendor.rs +++ b/crates/socket-patch-cli/src/commands/repair_vendor.rs @@ -464,11 +464,6 @@ fn rebuild_reason_label(code: &str) -> &str { } } -/// A soft (healthy-by-members, unanchored) reconstruction whose trustworthy -/// rebuild cannot proceed: the entry stays restored WITHOUT a whole-file -/// fingerprint — the legacy member-only state pass 1 keeps warning about -/// (`vendor_inventory_missing` for gems) — and the gap is surfaced, instead -/// of either failing the repair or canonizing the unverifiable live tree. /// The npm-family lockfiles and the vlt importers' package.json files as /// they are now, for the unverified-source rebuild's put-back. Read through /// the FIFO-safe opener: a FIFO or device at one of these paths is left out @@ -498,6 +493,11 @@ async fn snapshot_npm_wiring_files(cwd: &Path) -> Vec<(PathBuf, Option>) snap } +/// A soft (healthy-by-members, unanchored) reconstruction whose trustworthy +/// rebuild cannot proceed: the entry stays restored WITHOUT a whole-file +/// fingerprint — the legacy member-only state pass 1 keeps warning about +/// (`vendor_inventory_missing` for gems) — and the gap is surfaced, instead +/// of either failing the repair or canonizing the unverifiable live tree. /// The entry itself was already persisted by the pre-rebuild restore. fn soft_restore_without_fingerprint( env: &mut Envelope, @@ -652,7 +652,7 @@ async fn restore_orphaned_pre_rebuild_dirs(common: &GlobalArgs) { /// unreadable ledger fails this phase loudly (`vendor_state_unreadable`); /// the caller's own degrade-to-empty policy for its download scoping is /// its own. `run_client` is the run's API client when the caller already -/// built one (repair.rs's `telemetry_client`): the uuid lookups and the +/// built one (repair.rs's lazily built download-phase `client`): the uuid lookups and the /// staging fetch reuse it instead of constructing a second (or third) one /// and re-printing its token advisory; `None` builds lazily on first need. pub(crate) async fn repair_vendored_artifacts_with_references( @@ -866,7 +866,7 @@ pub(crate) async fn repair_vendored_artifacts_with_references( ArtifactHealth::Healthy => { // vlt's `/.gitignore` and `.gitattributes` are not // part of the artifact: a missing or edited one is simply - // rewritten (DESIGN §4.8). + // rewritten. if entry.ecosystem == "npm" && entry.flavor.as_deref() == Some(vendor::vlt_lock::FLAVOR) && !common.dry_run @@ -887,11 +887,12 @@ pub(crate) async fn repair_vendored_artifacts_with_references( // Dir-shaped artifacts from pre-inventory vendors: the // health check above could only verify the PATCHED members // — unpatched-file drift is invisible until a re-vendor - // records the whole-tree inventory. Name the gap — for gem - // only, the one backend that records inventories; the other - // dir-shaped backends (cargo/golang/composer) don't yet, so - // a re-vendor there records nothing and the advice would be - // permanent per-run noise. + // records the whole-tree inventory. Name the gap for gem + // only: vlt also records inventories but has no + // pre-inventory entries to warn about, and the other + // dir-shaped backends (cargo/golang/composer) don't record + // one, so a re-vendor there records nothing and the advice + // would be permanent per-run noise. if entry.ecosystem == "gem" && !artifact_is_file_shaped(&entry.artifact.path) && entry.artifact.file_inventory.is_none() @@ -1322,8 +1323,9 @@ pub(crate) async fn repair_vendored_artifacts_with_references( patches: records_map, setup: None, }; - // The ledger this pass already holds feeds the staging harvest; repair - // has no download phase, so no seed. + // The ledger this pass already holds feeds the staging harvest; repair's + // download phase writes blobs to disk (harvested from socket_dir), so + // there is no in-memory seed. let staged = match stage_vendor_sources_in_memory( common, &synth, @@ -2137,7 +2139,7 @@ mod tests { /// A FIFO under a wiring-file name (here the paired `