Repository navigation
Decide whether a hosted patch is pinned through lockfile discovery alone - #1058
Mikola Lysenko (mikolalysenko) wants to merge 8 commits into
Conversation
Discovery read the disk directly in ten places (the requirements -r walk, the Python lock listing, cargo's config resolution, Rush subspace listing, nuget's spelling and feed probes, maven's vendored jars, sbt's resolution evidence and hashes, vlt's bundled-store walk), so it could only run over a real checkout. Route the content reads and listings through ProjectView, keep the probes that need an installed tree behind DiscoverCtx::disk_root() (they see nothing in memory), and add discover_patched_refs_view() so the in-memory hosted engine can ask the same "is this pinned" question the disk commands ask. The view-based Python lock listing replaces the hosted engine's private copy, the requirements include walk and cargo's effective-config probe take a view, and the discovery future is boxed as Send (the in-memory engine's future must be Send; vlt's bundled-store pairs are collected first for the same reason). Behavior on disk is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The rollout had three answers to "is this patch already pinned": the disk scan's discovery pins, plus a second pass (mark_pinned) that counted any uuid-shaped token in any candidate text, and the in-memory engine's memory_recorded, which counted any mention of an offered uuid in any file. A stale hosted URL in a package.json field, an inactive pdm.lock or a comment therefore read as ALREADY, and the row rode past the --max-new-patches cap. Delete the mention scan (mentioned_uuids) and decide both engines' recorded view with HostedPin::discover, the discovery the management commands read. A pin on a patch server that is not configured is recognized once the run's references name it: both engines re-run discovery with those origins (foreign_dep_origins). The disk scan now makes that check before it decides whether to take the apply lock, so a run that writes a row found pinned this way locks before it reads what it writes. The late lock taken after an unlocked read is gone (audit B58). Lockless NuGet / Cargo pins (an exclusive Socket source without packages.lock.json, a registry pin without Cargo.lock) are contested wiring that nothing can attribute to a version. Their refusal no longer tells the user to re-run the hosted scan, which only writes the same pin again: it names the lockfile to create, and the pin's own grant token no longer shows up as a second contested patch (audit B13; whether such pins should be written at all stays with the E45 decision). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A hosted run confirmed a redirect when the rewriters' own report or a substring probe said its URL landed, while vex, list, rollback, remove and vendor read the same files through lockfile discovery. When the two disagreed the run reported success and wrote wiring every later command refused as contested, with a remedy (re-run the scan) that changed nothing: a Pipfile.lock rewired beside a requirements -r include that still pins the registry release (#567), a Maven pin inside a <profile> (#260). engine::rewrite now runs discovery over the project as the rewrite would leave it (a DiskSnapshot overlaid with the pending writes, or the in-memory project plus them) and reads it as the management commands do (HostedInventory). A confirmed candidate whose pin would be contested wiring is dropped and the rest rewritten without it; it is reported in redirect.skipped[] as redirect_unattributable, nothing is written for it, and the exit code is unchanged. Both the disk scan and the in-memory engine go through this one gate. Kept as they were, and documented: a pin in a file discovery does not read (a pre-2.6 bundler Gemfile, locked by the next bundle install), a deliberate partial redirect the run already reports (a bundled or bun-patched copy left on the registry, a dep withheld from the vlt rewrite while a sibling lock takes it; which unreachable copies should block a redirect is the audit's copy-source policy, B16), and lockless NuGet / Cargo pins, which are written with a new redirect_pin_lockless warning naming the lockfile to create (E45 decides whether they should be written at all). The engine's unit fixtures now use real uuids in their hosted urls, so discovery recognizes the pins they land. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A wet `scan --mode hosted` reverts a purl's vendored wiring and saves the ledger before the rewrite runs. The attribution gate then dropped such a purl when discovery contested its pin (the #567 shape: an unreached requirements -r include beside Pipfile.lock), so the run left the package on the unpatched registry release and exited 1, while the dry run, whose takeover previews never reach the gate, reported success. A takeover's uuid is now exempt from the veto (RewriteOptions:: takeover_uuids) and keeps the rewriters' verdict, as before the gate. The dry run and the wet run agree again. CLI_CONTRACT no longer claims the gate runs before anything is written. Also: - describe_skip_reason has human text for redirect_unattributable instead of blaming the server. - New tests: the takeover is never stranded (failed before this fix), the --json skip contract (reason, detail, redirected 0, exit 0), the redirect_pin_lockless warning for a lockless NuGet pin, and exit-code assertions on the #567 / #260 tests. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
memory_recorded now reads discovery's pins, which can include files under a nested root that discovery reaches through a requirements include or a rush subspace. Those pins are that root's own, as they were under the mention scan this replaced, so they are filtered out again. Pins to patches the API no longer offers now count (as on disk); the doc comment says so. Also document that DiskSnapshot::overlay does not change directory listings, and rename the vlt heal's private origin list to vlt_heal_origins so it is not confused with rollback's patch_server_origins (it also counts --api-url). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Gate omits configured patch-server origins
- Added patch_server_origins field to RewriteOptions and related types to pass the --patch-server-url allowlist through to the attribution gate's discovery, ensuring it uses the same origins as management commands.
Or push these changes by commenting:
@cursor push 4947219719
Preview (4947219719)
diff --git a/crates/socket-patch-cli/src/commands/list.rs b/crates/socket-patch-cli/src/commands/list.rs
--- a/crates/socket-patch-cli/src/commands/list.rs
+++ b/crates/socket-patch-cli/src/commands/list.rs
@@ -431,7 +431,10 @@
detail: detail.clone(),
});
} else if !args.common.silent {
- eprintln!("Warning: {}", crate::commands::rollback::capitalize_first(detail));
+ eprintln!(
+ "Warning: {}",
+ crate::commands::rollback::capitalize_first(detail)
+ );
}
}
let vendor_state = crate::commands::vendor_state_lenient(&loaded.vendor, args.common.silent);
@@ -773,12 +776,18 @@
let listings = HostedListing::from_pins(
&[
pin("pkg:npm/minimist@1.2.2", &record.uuid),
- pin("pkg:npm/other@1.0.0", "33333333-3333-4333-8333-333333333333"),
+ pin(
+ "pkg:npm/other@1.0.0",
+ "33333333-3333-4333-8333-333333333333",
+ ),
],
Some(&legacy),
);
assert_eq!(listings[0].record, record);
- assert_eq!(listings[1].record.uuid, "33333333-3333-4333-8333-333333333333");
+ assert_eq!(
+ listings[1].record.uuid,
+ "33333333-3333-4333-8333-333333333333"
+ );
assert!(listings[1].record.vulnerabilities.is_empty());
assert_eq!(listings[1].lockfiles, vec!["yarn.lock".to_string()]);
}
diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs
--- a/crates/socket-patch-cli/src/commands/mod.rs
+++ b/crates/socket-patch-cli/src/commands/mod.rs
@@ -1,7 +1,7 @@
pub mod apply;
pub(crate) mod bun_preflight;
+pub(crate) mod composer_hints;
pub(crate) mod context;
-pub(crate) mod composer_hints;
pub(crate) mod fetch_stage;
pub mod get;
pub mod hosted_bundle;
@@ -9,11 +9,11 @@
pub(crate) mod lock_cli;
pub mod remove;
pub mod repair;
-pub(crate) mod vendored_backend;
pub mod rollback;
pub mod scan;
pub mod update;
pub mod vendor;
+pub(crate) mod vendored_backend;
pub mod vex;
pub(crate) mod vex_consumed;
pub(crate) mod vex_sources;
@@ -136,9 +136,11 @@
common: &crate::args::GlobalArgs,
root: &Path,
) -> socket_patch_core::patch::redirect::RedirectState {
- hosted_state_from_pins(&socket_patch_core::patch::redirect::upstream::HostedPin::all(
- &discover_wiring(common, root).await,
- ))
+ hosted_state_from_pins(
+ &socket_patch_core::patch::redirect::upstream::HostedPin::all(
+ &discover_wiring(common, root).await,
+ ),
+ )
}
/// [`hosted_state_from_lockfiles`] over already-discovered pins. A purl
@@ -148,10 +150,8 @@
) -> socket_patch_core::patch::redirect::RedirectState {
let mut state = socket_patch_core::patch::redirect::RedirectState::new();
for pin in pins {
- state
- .records
- .entry(pin.purl.clone())
- .or_insert_with(|| socket_patch_core::manifest::schema::PatchRecord {
+ state.records.entry(pin.purl.clone()).or_insert_with(|| {
+ socket_patch_core::manifest::schema::PatchRecord {
uuid: pin.uuid.clone(),
exported_at: String::new(),
files: Default::default(),
@@ -159,7 +159,8 @@
description: String::new(),
license: String::new(),
tier: String::new(),
- });
+ }
+ });
}
state
}
@@ -186,4 +187,3 @@
}
}
}
-
diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs
--- a/crates/socket-patch-cli/src/commands/scan/discovery.rs
+++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs
@@ -168,29 +168,32 @@
}
// `(ledger key, base purl, entry)`; the artifact fallback has no
// entries to probe, so it never reports unwired keys.
- let candidates: Vec<(String, String, Option<&socket_patch_core::vendor::VendorEntry>)> =
- match state {
- Ok(state) => state
- .entries
- .iter()
- .map(|(key, entry)| {
- (
- key.clone(),
- strip_purl_qualifiers(&entry.base_purl).to_string(),
- Some(entry),
- )
- })
- .collect(),
- // 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
- .into_iter()
- .map(|base| (base.clone(), base, None))
- .collect(),
- };
+ let candidates: Vec<(
+ String,
+ String,
+ Option<&socket_patch_core::vendor::VendorEntry>,
+ )> = match state {
+ Ok(state) => state
+ .entries
+ .iter()
+ .map(|(key, entry)| {
+ (
+ key.clone(),
+ strip_purl_qualifiers(&entry.base_purl).to_string(),
+ Some(entry),
+ )
+ })
+ .collect(),
+ // 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
+ .into_iter()
+ .map(|base| (base.clone(), base, None))
+ .collect(),
+ };
// Composer by release identity: a ledger `@3.0.2.0` is the crawled
// `@3.0.2`, not a second package to supplement.
let key = |p: &str| composer_purl_identity(p).unwrap_or_else(|| normalize_purl(p).into_owned());
@@ -1045,7 +1048,9 @@
..GlobalArgs::default()
};
let state = socket_patch_core::vendor::load_state(root).await;
- vendored_ledger_supplement(&args, crawled, &state).await.packages
+ vendored_ledger_supplement(&args, crawled, &state)
+ .await
+ .packages
}
/// A ledger entry vendored as `@3.0.2.0` is the crawled composer
@@ -1080,7 +1085,9 @@
out.iter().map(|p| &p.purl).collect::<Vec<_>>()
);
- let out = vendored_ledger_supplement(&args, &[], &Ok(state)).await.packages;
+ let out = vendored_ledger_supplement(&args, &[], &Ok(state))
+ .await
+ .packages;
assert_eq!(
out.iter().map(|p| p.purl.as_str()).collect::<Vec<_>>(),
vec!["pkg:composer/psr/log@3.0.2.0"]
@@ -1183,7 +1190,10 @@
let state = npm_ledger_with_lock(tmp.path(), lock.as_deref()).await;
let out = vendored_ledger_supplement(&args, &[], &state).await;
assert_eq!(
- out.packages.iter().map(|p| p.purl.as_str()).collect::<Vec<_>>(),
+ out.packages
+ .iter()
+ .map(|p| p.purl.as_str())
+ .collect::<Vec<_>>(),
vec!["pkg:npm/left-pad@1.3.0"],
"lock={lock:?}"
);
diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs
--- a/crates/socket-patch-cli/src/commands/scan/hosted.rs
+++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs
@@ -1023,7 +1023,11 @@
.map(|c| c.dep.patch_uuid.clone())
.collect()
};
- let rewrite_options = || RewriteOptions {
+ // The `--patch-server-url` allowlist: extra patch-server origins the
+ // attribution gate recognizes when discovering attributable pins.
+ let patch_server_origins = crate::commands::rollback::patch_server_origins(common);
+ let rewrite_options = || {
+ RewriteOptions {
dry_run: common.dry_run,
targets_pipenv_lock,
pipenv_major,
@@ -1036,6 +1040,8 @@
npm_outer: &npm_outer,
blocking: true,
takeover_uuids: takeover_uuids.clone(),
+ patch_server_origins: patch_server_origins.clone(),
+ }
};
// The rollout gate plans again without its deferred rows: keep what
// the second pass needs.
@@ -4787,19 +4793,43 @@
use super::npm_allow_remote_one_line;
let hosts = ["patch.socket.dev"];
let cases = [
- (npm_allow_remote_configured_detail(&hosts, true, false), "Note: set"),
- (npm_allow_remote_configured_detail(&hosts, false, false), "Note: set"),
- (npm_allow_remote_configured_detail(&hosts, true, true), "Note: would set"),
- (npm_allow_remote_already_detail(&hosts), "Note: .npmrc already"),
- (npm_allow_remote_user_set_detail(&hosts, "none"), "Warning: npm >=12"),
- (npm_allow_remote_env_set_detail(&hosts, "npm_config_allow_remote", "none"), "Warning: npm >=12"),
+ (
+ npm_allow_remote_configured_detail(&hosts, true, false),
+ "Note: set",
+ ),
+ (
+ npm_allow_remote_configured_detail(&hosts, false, false),
+ "Note: set",
+ ),
+ (
+ npm_allow_remote_configured_detail(&hosts, true, true),
+ "Note: would set",
+ ),
+ (
+ npm_allow_remote_already_detail(&hosts),
+ "Note: .npmrc already",
+ ),
+ (
+ npm_allow_remote_user_set_detail(&hosts, "none"),
+ "Warning: npm >=12",
+ ),
+ (
+ npm_allow_remote_env_set_detail(&hosts, "npm_config_allow_remote", "none"),
+ "Warning: npm >=12",
+ ),
(npm_allow_remote_manual_detail(&hosts), "Warning: npm >=12"),
- (npm_allow_remote_unreadable_detail(&hosts, "is a symlink"), "Warning: npm >=12"),
+ (
+ npm_allow_remote_unreadable_detail(&hosts, "is a symlink"),
+ "Warning: npm >=12",
+ ),
];
for (detail, start) in cases {
let line = npm_allow_remote_one_line(&detail);
assert!(line.starts_with(start), "{line}");
- assert!(!line.contains('\n') && line.ends_with("(details: --verbose)."), "{line}");
+ assert!(
+ !line.contains('\n') && line.ends_with("(details: --verbose)."),
+ "{line}"
+ );
}
}
}
diff --git a/crates/socket-patch-cli/src/commands/scan/policy.rs b/crates/socket-patch-cli/src/commands/scan/policy.rs
--- a/crates/socket-patch-cli/src/commands/scan/policy.rs
+++ b/crates/socket-patch-cli/src/commands/scan/policy.rs
@@ -11,9 +11,9 @@
use socket_patch_core::api::types::PatchSearchResult;
use socket_patch_core::manifest::schema::PatchManifest;
use socket_patch_core::policy::{
- canon, find_repo_root_with_warnings, policy_block, FilteredEntry, RetainedEntry, patch_severity_order, repo_relative_checked, sanitize, severity_name,
- DiskPolicyFs, FilterReason, Offers, PolicyError, PolicySource, PolicyWarning, Root, SelectionPolicy,
- PATCHES_DISABLED,
+ canon, find_repo_root_with_warnings, patch_severity_order, policy_block, repo_relative_checked,
+ sanitize, severity_name, DiskPolicyFs, FilterReason, FilteredEntry, Offers, PolicyError,
+ PolicySource, PolicyWarning, RetainedEntry, Root, SelectionPolicy, PATCHES_DISABLED,
};
use socket_patch_core::utils::purl::normalize_purl;
@@ -42,12 +42,18 @@
/// Load the policy for `args` (4.5): `--global` scans have no repo and read
/// no file; everything else reads the repo root's socket.yml.
pub(crate) fn load_invocation_policy(args: &ScanArgs) -> Result<InvocationPolicy, PolicyLoadError> {
- let overrides = args.socket_yml.overrides().map_err(PolicyLoadError::Usage)?;
+ let overrides = args
+ .socket_yml
+ .overrides()
+ .map_err(PolicyLoadError::Usage)?;
let cwd = std::fs::canonicalize(&args.common.cwd).unwrap_or_else(|_| args.common.cwd.clone());
if args.common.is_global() {
- let policy = SelectionPolicy::load(&socket_patch_core::policy::MemoryPolicyFs::default(), &overrides)
- .map_err(PolicyLoadError::Policy)?
- .0;
+ let policy = SelectionPolicy::load(
+ &socket_patch_core::policy::MemoryPolicyFs::default(),
+ &overrides,
+ )
+ .map_err(PolicyLoadError::Policy)?
+ .0;
return Ok(InvocationPolicy {
policy,
repo_root: cwd,
@@ -56,8 +62,8 @@
});
}
let (repo_root, mut warnings) = find_repo_root_with_warnings(&cwd);
- let (policy, load_warnings) =
- SelectionPolicy::load(&DiskPolicyFs::new(&repo_root), &overrides).map_err(PolicyLoadError::Policy)?;
+ let (policy, load_warnings) = SelectionPolicy::load(&DiskPolicyFs::new(&repo_root), &overrides)
+ .map_err(PolicyLoadError::Policy)?;
warnings.extend(load_warnings);
Ok(InvocationPolicy {
policy,
@@ -141,7 +147,12 @@
impl ScanPolicy {
/// The policy for the project rooted at `root_dir`.
- pub(crate) fn for_root(invocation: &InvocationPolicy, root_dir: &Path, explicit: bool, global: bool) -> Self {
+ pub(crate) fn for_root(
+ invocation: &InvocationPolicy,
+ root_dir: &Path,
+ explicit: bool,
+ global: bool,
+ ) -> Self {
let root_dir = std::fs::canonicalize(root_dir).unwrap_or_else(|_| root_dir.to_path_buf());
let project = repo_relative_checked(&invocation.repo_root, &root_dir).unwrap_or_default();
let root_verdict = if global {
@@ -174,7 +185,9 @@
severity: None,
});
}
- let announce_warnings = !invocation.warned.swap(true, std::sync::atomic::Ordering::Relaxed);
+ let announce_warnings = !invocation
+ .warned
+ .swap(true, std::sync::atomic::Ordering::Relaxed);
Self {
policy: invocation.policy.clone(),
warnings,
@@ -227,7 +240,10 @@
/// exclude stays in the query (so `upgradeAvailable` can be reported)
/// but joins the retained set, which never reaches a writer.
pub(crate) fn admit_crawled(&self, purl: &str) -> bool {
- let verdict = self.root_verdict.clone().and_then(|()| self.policy.admits_purl(purl));
+ let verdict = self
+ .root_verdict
+ .clone()
+ .and_then(|()| self.policy.admits_purl(purl));
let reason = match verdict {
Ok(()) => return true,
Err(reason) => reason,
@@ -337,7 +353,8 @@
// (not when a lower-ranked admitted patch simply wins).
let top_withheld = self.policy.admits_severity(patch_severity_order(&group[0]));
if let Err(reason) = top_withheld {
- let upgrade_withheld = chosen.is_some() && chosen == recorded_at && recorded_at != Some(0);
+ let upgrade_withheld =
+ chosen.is_some() && chosen == recorded_at && recorded_at != Some(0);
if chosen.is_none() || upgrade_withheld {
report.filtered.push(FilteredEntry {
purl: Some(canon(&purl)),
@@ -525,17 +542,20 @@
let verdict = if !policy.enabled() {
Err(FilterReason::Disabled)
} else {
- root_verdict.clone().and_then(|()| policy.admits_purl(purl)).and_then(|()| {
- // The floor only hides a package when none of its patches pass.
- match group
- .iter()
- .map(|p| policy.admits_severity(patch_severity_order(p)))
- .find(Result::is_ok)
- {
- Some(ok) => ok,
- None => policy.admits_severity(patch_severity_order(group[0])),
- }
- })
+ root_verdict
+ .clone()
+ .and_then(|()| policy.admits_purl(purl))
+ .and_then(|()| {
+ // The floor only hides a package when none of its patches pass.
+ match group
+ .iter()
+ .map(|p| policy.admits_severity(patch_severity_order(p)))
+ .find(Result::is_ok)
+ {
+ Some(ok) => ok,
+ None => policy.admits_severity(patch_severity_order(group[0])),
+ }
+ })
};
if let Err(reason) = verdict {
out.push((
diff --git a/crates/socket-patch-cli/src/commands/scan/rollout.rs b/crates/socket-patch-cli/src/commands/scan/rollout.rs
--- a/crates/socket-patch-cli/src/commands/scan/rollout.rs
+++ b/crates/socket-patch-cli/src/commands/scan/rollout.rs
@@ -4,8 +4,10 @@
use std::collections::{BTreeMap, BTreeSet, HashSet};
-use socket_patch_core::rollout::{canonical_base_purl, severity_label, MaxNew, MaxNewSource, Recorded, RolloutPlan};
pub(crate) use socket_patch_core::rollout::stage::*;
+use socket_patch_core::rollout::{
+ canonical_base_purl, severity_label, MaxNew, MaxNewSource, Recorded, RolloutPlan,
+};
use super::discovery::UpdateInfo;
@@ -208,11 +210,11 @@
mod tests {
use super::*;
use socket_patch_core::api::types::PatchSearchResult;
+ use socket_patch_core::api::types::VulnerabilityResponse;
use socket_patch_core::manifest::schema::PatchManifest;
- use std::path::Path;
- use socket_patch_core::api::types::VulnerabilityResponse;
use socket_patch_core::manifest::schema::PatchRecord;
use std::collections::HashMap;
+ use std::path::Path;
fn offer(purl: &str, uuid: &str, published: &str, severities: &[&str]) -> PatchSearchResult {
PatchSearchResult {
@@ -357,13 +359,21 @@
let stored = manifest(&[("pkg:composer/psr/log@3.0.2.0", "old")]);
let recorded = RecordedIndex::new(Some(&stored), &[]);
let offers = offers_from_results(
- &[offer("pkg:composer/psr/log@v3.0.2", "new", "2026-02-01T00:00:00Z", &["high"])],
+ &[offer(
+ "pkg:composer/psr/log@v3.0.2",
+ "new",
+ "2026-02-01T00:00:00Z",
+ &["high"],
+ )],
false,
);
let rows = classify(&offers, &recorded, "");
let plan = socket_patch_core::rollout::plan_rollout(
rows.into_iter().map(|row| row.candidate).collect(),
- &MaxNew { value: Some(0), source: MaxNewSource::Flag },
+ &MaxNew {
+ value: Some(0),
+ source: MaxNewSource::Flag,
+ },
false,
&BTreeSet::new(),
);
diff --git a/crates/socket-patch-cli/src/commands/scan/rollout_args.rs b/crates/socket-patch-cli/src/commands/scan/rollout_args.rs
--- a/crates/socket-patch-cli/src/commands/scan/rollout_args.rs
+++ b/crates/socket-patch-cli/src/commands/scan/rollout_args.rs
@@ -1,7 +1,6 @@
//! `scan --max-new-patches` (see the rollout guide,
//! `docs/configuration.md#gradual-rollout`).
-
use clap::Args;
pub(crate) use socket_patch_core::rollout::stage::RolloutCarry;
use socket_patch_core::rollout::{resolve_max_new, MaxNew};
@@ -77,7 +76,6 @@
}
}
-
#[cfg(test)]
mod tests {
use super::*;
diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs
--- a/crates/socket-patch-cli/src/commands/vendor.rs
+++ b/crates/socket-patch-cli/src/commands/vendor.rs
@@ -442,10 +442,7 @@
/// entry (fail-safe): ecosystems other than npm, cargo and pypi (whose
/// probe covers the requirements flavor only) have no in-use probe yet,
/// and a missing/unreadable lockfile proves nothing.
-pub(crate) async fn dispatch_in_use_one(
- entry: &VendorEntry,
- project_root: &Path,
-) -> Option<bool> {
+pub(crate) async fn dispatch_in_use_one(entry: &VendorEntry, project_root: &Path) -> Option<bool> {
match entry.ecosystem.as_str() {
"npm" => vendor::npm_flavor::vendored_entry_in_use(entry, project_root).await,
// Cargo probes the lock entry's shape: detached + `[patch]` pointing
@@ -1237,8 +1234,7 @@
// know (the ledger was ignored or dropped from the commit along with the
// manifest) leaves every fresh install failing; the manifest keys above
// cannot see it, so the references are read from the wiring itself.
- let references =
- crate::commands::vendored_backend::repair::scan_vendor_references(root).await;
+ let references = crate::commands::vendored_backend::repair::scan_vendor_references(root).await;
for (eco, uuid, rel) in references {
let ledgered = state
.entries
diff --git a/crates/socket-patch-cli/tests/apply/apply_network.rs b/crates/socket-patch-cli/tests/apply/apply_network.rs
--- a/crates/socket-patch-cli/tests/apply/apply_network.rs
+++ b/crates/socket-patch-cli/tests/apply/apply_network.rs
@@ -940,7 +940,10 @@
"a legacy package archive must not cover the patch; stdout={stdout}\nstderr={stderr}"
);
let content = std::fs::read(tmp.path().join("node_modules/pkgcache/index.js")).unwrap();
- assert_eq!(content, before, "the file must not be patched from the legacy archive");
+ assert_eq!(
+ content, before,
+ "the file must not be patched from the legacy archive"
+ );
let requests = mock.received_requests().await.unwrap_or_default();
let blob_path = format!("/v0/orgs/{ORG_SLUG}/patches/blob/{after_hash}");
@@ -1043,10 +1046,7 @@
v["summary"]["applied"], 1,
"the drifted nested copy must be warn-overwritten.\nstdout={v:#}"
);
- assert_eq!(
- v["summary"]["failed"], 0,
- "no copy may fail.\nstdout={v:#}"
- );
+ assert_eq!(v["summary"]["failed"], 0, "no copy may fail.\nstdout={v:#}");
// The nested copy's blob was fetched on demand…
let requests = mock.received_requests().await.unwrap();
diff --git a/crates/socket-patch-cli/tests/apply/in_process_gem_config_warning.rs b/crates/socket-patch-cli/tests/apply/in_process_gem_config_warning.rs
--- a/crates/socket-patch-cli/tests/apply/in_process_gem_config_warning.rs
+++ b/crates/socket-patch-cli/tests/apply/in_process_gem_config_warning.rs
@@ -201,7 +201,9 @@
"non-silent stderr must carry the {CODE} warning; got:\n{stderr}"
);
assert_eq!(
- stderr.matches("Warning: bundler app config BUNDLE_PATH").count(),
+ stderr
+ .matches("Warning: bundler app config BUNDLE_PATH")
+ .count(),
1,
"exactly ONE warning line (not one per discovery call); got:\n{stderr}"
);
diff --git a/crates/socket-patch-cli/tests/cli/covgap_output.rs b/crates/socket-patch-cli/tests/cli/covgap_output.rs
--- a/crates/socket-patch-cli/tests/cli/covgap_output.rs
+++ b/crates/socket-patch-cli/tests/cli/covgap_output.rs
@@ -168,9 +168,8 @@
.expect("spawn socket-patch in PTY");
drop(pair.slave);
- let reader_handle = crate::pty_io::PtyOutput::spawn(
- pair.master.try_clone_reader().expect("clone reader"),
- );
+ let reader_handle =
+ crate::pty_io::PtyOutput::spawn(pair.master.try_clone_reader().expect("clone reader"));
// Watchdog: detached kill after `timeout`; a no-op if the child exits
// naturally first.
@@ -261,7 +260,10 @@
"\n",
Duration::from_secs(15),
);
- assert_eq!(code, 0, "remove with bare Enter must succeed; got: {output}");
+ assert_eq!(
+ code, 0,
+ "remove with bare Enter must succeed; got: {output}"
+ );
// The interactive confirm MUST have run — otherwise this test passes
// vacuously against a regression that drops the TTY gate and
// auto-proceeds. Match the distinctive prompt verbatim (the loose
diff --git a/crates/socket-patch-cli/tests/cli/interactive_prompts_e2e.rs b/crates/socket-patch-cli/tests/cli/interactive_prompts_e2e.rs
--- a/crates/socket-patch-cli/tests/cli/interactive_prompts_e2e.rs
+++ b/crates/socket-patch-cli/tests/cli/interactive_prompts_e2e.rs
@@ -112,9 +112,8 @@
// closed. The previous design used a chunked read+mpsc loop
// because it interleaved with a try_wait poll; the simplified
// design serializes wait → drop master → read_to_end joins.
- let reader_handle = crate::pty_io::PtyOutput::spawn(
- pair.master.try_clone_reader().expect("clone reader"),
- );
+ let reader_handle =
+ crate::pty_io::PtyOutput::spawn(pair.master.try_clone_reader().expect("clone reader"));
// Watchdog: detach a thread that kills the child after `timeout`.
// The cloned ChildKiller is independent of the main `child`
diff --git a/crates/socket-patch-cli/tests/cli_config_fallback.rs b/crates/socket-patch-cli/tests/cli_config_fallback.rs
--- a/crates/socket-patch-cli/tests/cli_config_fallback.rs
+++ b/crates/socket-patch-cli/tests/cli_config_fallback.rs
@@ -59,8 +59,7 @@
let mut cmd = Command::new(BINARY);
// Human mode: core's proxy advisory (the oracle below) is muted under
// `--json`/`--silent`.
- cmd.args(["scan", "-e", "npm", "--cwd"])
- .arg(project);
+ cmd.args(["scan", "-e", "npm", "--cwd"]).arg(project);
for (key, _) in std::env::vars_os() {
let name = key.to_string_lossy();
if name.starts_with("SOCKET_") {
@@ -298,7 +297,9 @@
json_cmd.arg("--json");
let json_out = run(json_cmd);
assert!(
- json_out.stderr.contains("could not parse socket-cli config"),
+ json_out
+ .stderr
+ .contains("could not parse socket-cli config"),
"the parse warning must reach stderr under --json too; got:\n{}",
json_out.stderr
);
diff --git a/crates/socket-patch-cli/tests/cli_get_silent.rs b/crates/socket-patch-cli/tests/cli_get_silent.rs
--- a/crates/socket-patch-cli/tests/cli_get_silent.rs
+++ b/crates/socket-patch-cli/tests/cli_get_silent.rs
@@ -25,10 +25,7 @@
for var in GLOBAL_ARG_ENV_VARS {
cmd.env_remove(var);
}
- for var in [
- "SOCKET_SAVE_ONLY",
- "SOCKET_ALL_RELEASES",
- ] {
+ for var in ["SOCKET_SAVE_ONLY", "SOCKET_ALL_RELEASES"] {
cmd.env_remove(var);
}
cmd.env("SOCKET_TELEMETRY_DISABLED", "1");
diff --git a/crates/socket-patch-cli/tests/cli_parse_list.rs b/crates/socket-patch-cli/tests/cli_parse_list.rs
--- a/crates/socket-patch-cli/tests/cli_parse_list.rs
+++ b/crates/socket-patch-cli/tests/cli_parse_list.rs
@@ -370,7 +370,11 @@
let out = run_list_binary(tmp.path(), &["--json"]);
let v: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim())
.expect("stdout must be valid JSON envelope");
- assert_eq!(out.status.code(), Some(0), "missing manifest is an empty list");
+ assert_eq!(
+ out.status.code(),
+ Some(0),
+ "missing manifest is an empty list"
+ );
assert_eq!(v["status"], "success", "envelope: {v}");
assert_eq!(v["summary"]["discovered"], 0, "envelope: {v}");
}
@@ -1313,7 +1317,10 @@
assert_eq!(v["status"], "success", "envelope={v}");
let warnings = v["warnings"].as_array().expect("warnings[] present");
assert_eq!(warnings.len(), 1, "envelope={v}");
- assert_eq!(warnings[0]["code"], "redirect_ledger_corrupt", "envelope={v}");
+ assert_eq!(
+ warnings[0]["code"], "redirect_ledger_corrupt",
+ "envelope={v}"
+ );
assert!(
out.stderr.is_empty(),
"--json must keep stderr clean: {}",
diff --git a/crates/socket-patch-cli/tests/cli_parse_rollback.rs b/crates/socket-patch-cli/tests/cli_parse_rollback.rs
--- a/crates/socket-patch-cli/tests/cli_parse_rollback.rs
+++ b/crates/socket-patch-cli/tests/cli_parse_rollback.rs
@@ -366,7 +366,11 @@
/// relied on the rejection get a test-visible flip instead of a silent one.
#[test]
fn multiple_targets_parse_in_order() {
- let args = parse_rollback(&["pkg:npm/foo@1", "packages/api/**", "b0630680-4da6-45f9-bba8-b888e0ffd58c"]);
+ let args = parse_rollback(&[
+ "pkg:npm/foo@1",
+ "packages/api/**",
+ "b0630680-4da6-45f9-bba8-b888e0ffd58c",
+ ]);
assert_eq!(
args.targets,
vec![
diff --git a/crates/socket-patch-cli/tests/cli_parse_scan.rs b/crates/socket-patch-cli/tests/cli_parse_scan.rs
--- a/crates/socket-patch-cli/tests/cli_parse_scan.rs
+++ b/crates/socket-patch-cli/tests/cli_parse_scan.rs
@@ -898,7 +898,11 @@
("NONE", None),
] {
let args = parse_scan(&["--max-new-patches", raw]);
- assert_eq!(args.rollout.max_new_patches, Some(MaxNewPatches(want)), "{raw}");
+ assert_eq!(
+ args.rollout.max_new_patches,
+ Some(MaxNewPatches(want)),
+ "{raw}"
+ );
}
}
@@ -989,20 +993,33 @@
assert_eq!(parse_scan(&[]).socket_yml.min_severity, None);
assert_eq!(overrides(&[], &[]).unwrap().min_severity, None);
assert_eq!(
- overrides(&["--min-severity", "High"], &[]).unwrap().min_severity,
+ overrides(&["--min-severity", "High"], &[])
+ .unwrap()
+ .min_severity,
Some((Some(1), OverrideSource::Flag))
);
assert_eq!(
- overrides(&["--min-severity", "none"], &[("SOCKET_MIN_SEVERITY", "critical")]).unwrap().min_severity,
+ overrides(
+ &["--min-severity", "none"],
+ &[("SOCKET_MIN_SEVERITY", "critical")]
+ )
+ .unwrap()
+ .min_severity,
Some((None, OverrideSource::Flag))
);
assert_eq!(
- overrides(&[], &[("SOCKET_MIN_SEVERITY", "moderate")]).unwrap().min_severity,
+ overrides(&[], &[("SOCKET_MIN_SEVERITY", "moderate")])
+ .unwrap()
+ .min_severity,
Some((Some(2), OverrideSource::Env))
);
- assert_eq!(overrides(&[], &[("SOCKET_MIN_SEVERITY", "")]).unwrap().min_severity, None);
+ assert_eq!(
+ overrides(&[], &[("SOCKET_MIN_SEVERITY", "")])
+ .unwrap()
+ .min_severity,
+ None
+ );
assert!(overrides(&[], &[("SOCKET_MIN_SEVERITY", "severe")]).is_err());
assert!(try_parse_scan(&["--min-severity", "severe"]).is_err());
assert!(overrides(&["--no-socket-yml"], &[]).unwrap().bypass);
}
-
diff --git a/crates/socket-patch-cli/tests/coverage_fix_apply_silent_mute_exit.rs b/crates/socket-patch-cli/tests/coverage_fix_apply_silent_mute_exit.rs
--- a/crates/socket-patch-cli/tests/coverage_fix_apply_silent_mute_exit.rs
+++ b/crates/socket-patch-cli/tests/coverage_fix_apply_silent_mute_exit.rs
@@ -149,7 +149,9 @@
);
let chatter = stderr_chatter(&stderr);
assert!(
- chatter.iter().any(|l| l.contains("could not be downloaded")),
+ chatter
+ .iter()
+ .any(|l| l.contains("could not be downloaded")),
"--silent must keep the download-failure error (errors only, \
never nothing); stderr was: {stderr:?}"
);
diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs
--- a/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs
+++ b/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs
@@ -566,8 +566,7 @@
"the human skipped line must name purl + reason; stderr=\n{stderr}"
);
assert!(
- stderr.contains("Warning: ")
- && stderr.contains("could not be reverted"),
+ stderr.contains("Warning: ") && stderr.contains("could not be reverted"),
"the takeover pre-warning must reach human stderr; stderr=\n{stderr}"
);
}
@@ -814,7 +813,10 @@
let lock_before = std::fs::read(root.join("package-lock.json")).unwrap();
let assert_ignored = |code: i32, doc: &Value, label: &str| {
- assert_eq!(code, 0, "{label}: a pre-v5 ledger is never an error: {doc:#}");
+ assert_eq!(
+ code, 0,
+ "{label}: a pre-v5 ledger is never an error: {doc:#}"
+ );
assert_eq!(doc["status"], "success", "{label}: {doc:#}");
assert!(
!doc.to_string().contains("redirect-state.json"),
@@ -1017,7 +1019,10 @@
for extra in [&[][..], &["--silent"][..]] {
let (code, stdout, stderr) = scan_hosted(root, &server.uri(), extra, &[]);
- assert_eq!(code, 0, "{extra:?}: an empty discovery exits 0; stderr=\n{stderr}");
+ assert_eq!(
+ code, 0,
+ "{extra:?}: an empty discovery exits 0; stderr=\n{stderr}"
+ );
if extra.is_empty() {
assert!(
stdout.contains("No patches available for installed packages."),
@@ -1404,16 +1409,22 @@
],
&env,
);
- assert_eq!(code, 1, "a binary bun.lockb pin is refused: {stdout}\n{stderr}");
+ assert_eq!(
+ code, 1,
+ "a binary bun.lockb pin is refused: {stdout}\n{stderr}"
+ );
let doc: Value = serde_json::from_str(&stdout).unwrap_or_else(|e| panic!("{e}: {stdout}"));
... diff truncated: showing 800 of 6730 linesYou can send follow-ups to the cloud agent here.
same_file returned false on every non-Unix platform, so on Windows' case-insensitive filesystem the one nuget.config was also recognized as NuGet.config and NuGet.Config. A contested-wiring refusal then named the file three times, and its `git checkout --` remedy listed three paths for one file, which lockless_nuget_pin_refusal_names_the_lockfile_remedy caught on windows-latest. Compare volume serial + file index on Windows through the same-file crate the core already depends on. Unix keeps its stat-only dev + inode check so a FIFO under a config name is never opened. Co-Authored-By: Claude <noreply@anthropic.com>
|
bugbot run Generated by Claude Code |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
Bugbot Autofix is ON. A cloud agent has been kicked off to fix the reported issue.
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 5f0b71c. Configure here.
…grades The attribution gate discovered with only the hosts this run's grants name, not the operator's --patch-server-url allowlist that vex, list, rollback, remove and vendor read. An existing pin on a configured server was invisible to the gate whenever this run's grants lived on another host, so a write those commands would refuse as contested could land. RewriteOptions now carries the configured origins and the gate discovers over them plus the grants' hosts. The second rollout pass (mark_pinned) only flipped a NEW row to ALREADY when the selected uuid was pinned on a server only this run's references name. An older patch pinned there stayed NEW, so an upgrade spent a NEW cap slot (in both engines; in memory always, since it has no configured origins). It now marks such a row Superseded. Addresses Bugbot findings on #1058. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
[agent] Blocked: CI still pending on 8da8a1a, so this is not marked ready yet.
Generated by Claude Code |
|
[agent] Local perf-profile bench (
CPU is up about 20% and peak memory about 10%. Cause: Planned fix (next pass):
Generated by Claude Code |
|
[agent] Needs a human decision: is the Two fix attempts this run. The second reuses scan's pre-rewrite discovery in the
npm/hosted still regresses because a rewrite that writes files needs a full fresh discovery over the overlay, and the cost is lockfile parsing. Closing that gap means also dropping the post-write Question: should we
Generated by Claude Code |


Problem
Audit §3.B, "is this pinned": four places decided whether a patch uuid is pinned, and they disagreed.
engine::confirmused the rewriters' reports plus substring needles.rollout::mark_pinnedcounted any uuid-shaped token in any candidate text.memory_recordedcounted any mention of an offered uuid in any file.vex::discover/HostedInventory(whatvex,list,rollback,removeandvendorread) parses each format.What that caused:
package.jsonfield, an inactivepdm.lockor a comment read as ALREADY, so the row slipped past--max-new-patches. Both engines did this.-rinclude rewires only Pipfile.lock, then reports success while vex and rollback refuse the contested lock it just created #567. Hosted Pipenv rewiredPipfile.lockbeside a requirements-rinclude that still pins the registry release. The scan reported success;vexandrollbackthen refused the contested lock, and the suggested remedy (re-run the scan) changed nothing.vexrefuses and Maven never enforces #260. A Maven pin that Maven never consumes (a<profile>-scoped dependency) was confirmed by substring.socket-patch scan --mode hosted").Change
Lockfile discovery is now the only answer to "is this pinned". Three commits:
ProjectView.DiscoverCtx::disk_root()and see nothing in memory.discover_patched_refs_view().ProjectView::python_lock_paths().mentioned_uuidsand the mention scan inmemory_recordedare deleted.HostedPin::discover. Two behavior changes in the in-memory engine: a pin now counts even when the API no longer offers its patch (as on disk, so a re-scan re-confirms it as ALREADY instead of spending a NEW slot), and pins wired only by files under a nested root are still excluded, as the old mention scan did.foreign_dep_origins). This replacesmark_pinned's reason to exist.ContestedWiring.lockless. The refusal names the lockfile to create (dotnet restore --use-lock-file/cargo generate-lockfile) instead of a scan re-run.UnlockedPin.index_url).engine::rewritenow runs discovery over the project as the rewrite would leave it: aDiskSnapshot::overlayon disk, or the in-memory project plus the pending writes.HostedInventory, the management commands' own view.redirect.skipped[]asredirect_unattributable; nothing is written for it and the exit code is unchanged.-rinclude rewires only Pipfile.lock, then reports success while vex and rollback refuse the contested lock it just created #567 and Hosted Maven "redirected" is confirmed by substring, so the scan envelope and ledger claim patches thatvexrefuses and Maven never enforces #260.Kept as is, and documented in CLI_CONTRACT ("Attribution gate"):
A pin in a file discovery does not read keeps the rewriter's verdict. Example: a pre-2.6 bundler
Gemfile, which the nextbundle installlocks.Deliberate partial redirects the run already reports keep their behavior:
bun patch-ed copies (bundled_skipped_uuids)Which unreachable copies should block a redirect is the copy-source policy (audit B16).
A vendored→hosted takeover is never vetoed. A wet run has already reverted its vendored wiring and saved the ledger before the rewrite, so dropping it would leave the package on the unpatched registry release (exit 1) while the dry run reported success. Its pin keeps the rewriters' verdict, as before this PR, and the dry run and wet run agree (
RewriteOptions::takeover_uuids).Lockless NuGet / Cargo pins are still written. They now carry a
redirect_pin_locklesswarning.Duplicate copies deleted (before → after)
confirmneedles,mark_pinned/mentioned_uuids,memory_recordedmention scan, discoveryHostedPin::discover/HostedInventory).confirm()'s needles stay as a "did the write land" pre-check that discovery can veto, and they still decide pins in files discovery does not read. The in-run--vexRedirectStatecarrier is deferred to E45. Counted strictly, 4 → 2.hosted::engine::python_lock_pathsand the discovery disk callProjectView::python_lock_pathscommands::discover_optionsandrollback::patch_server_origins--api-url, is renamedvlt_heal_originsand documented, so the two names no longer collide.Testing
Failing-first: each new regression test was run against code without its fix and failed there.
hosted_memory_rollout::a_uuid_mentioned_outside_a_pin_is_new_not_already: cap bypass, disk and in-memory parity.in_process_redirect_pipenv::an_unreached_requirements_include_vetoes_the_pipfile_lock_redirect: Hosted scan in a Pipenv project whose requirements.txt pins the package through an-rinclude rewires only Pipfile.lock, then reports success while vex and rollback refuse the contested lock it just created #567 (now also asserts exit 0).in_process_get_hosted_ecosystems::maven_profile_scoped_dependency_is_not_redirected: Hosted Maven "redirected" is confirmed by substring, so the scan envelope and ledger claim patches thatvexrefuses and Maven never enforces #260 (now also asserts exit 0).core tests/hosted_inventory::lockless_nuget_pin_refusal_names_the_lockfile_remedy: B13 remedy.mode_migration_pypi::pipenv_takeover_beside_an_unreached_include_is_never_stranded: a vendored Pipenv project plus an unreached-rinclude; the dry run and wet run agree and the dep ends up pinned to the patch. Before the takeover exemption the wet run exited 1 withredirect_takeover_unpatched.mode_migration_pypi::pipenv_redirect_beside_an_unreached_include_is_skipped_unattributable:--jsoncontract (skipped[].reason == redirect_unattributable, detail names the contesting file,redirected == 0, exit 0, nothing written).hosted::engine::tests::a_lockless_nuget_pin_is_written_with_the_lockless_warning: theredirect_pin_locklesswarning and itsdotnet restore --use-lock-fileremedy.describe_skip_reasonunit test covers the newredirect_unattributabletext.Commands run (through heavy-job.sh,
CARGO_INCREMENTAL=0,-j4), on head e25c17d over origin/main 431b818:cargo test -p socket-patch-core --no-fail-fast: 6238 passed, 0 failed.cargo test -p socket-patch-cli --no-fail-fast: 5129 passed, 3 failed. All 3 are unrelated and fail the same way before this PR:mode_migration_npm::berry_vendored_then_hosted_takeover_leaves_pure_hostedfails on the base commit too.e2e_vendor_cargo_build::cargo_vendored_{manifest_patch_builds_on_old_toolchains,two_versions_are_refused_by_cargo_below_1_45}fail locally because the old x86_64 rustup toolchains can't run on arm64 ("Bad CPU type").cargo clippy -p socket-patch-core -p socket-patch-cli --all-features -- -D warnings: clean apart from the macOS-onlyunused_variablesinpython_crawler.rs, which already fails on main on this host (checked with-A unused-variables).cargo clippy --all-targets: no findings in touched files.Linux and docker suites are left to CI.
Deferred
DiskSnapshotoverlay (or the in-memory project). A veto round, and the rollout second pass, each run another. On a large monorepo that is 1 to 3 extra discovery passes per hosted scan, which cuts into the Speed up hosted and vendored scans: concurrent API requests, parallel crawl, single-pass rewriters #257 speedups. Possible fixes: reuse the scan's own snapshot for files the rewrite did not touch, or skip the gate when no rewritten file is a format discovery reads. The bench suite should be checked before merge.DiskSnapshot::overlaychanges content reads and existence probes, not directory listings. A file a rewrite creates that discovery only finds by listing (a newpylock.*.toml, a new rush subspace lock) would bypass the gate. No hosted rewriter creates one today; the limit is documented onoverlay().--vexstill attests them through this run's records injected as a fakeRedirectState. Whether they should be written, made restorable or refused is E45, so I left it undecided. Replacing theRedirectStatecarrier with a typedHostedRecordsbelongs with that decision.confirm()'s substring needles are not deleted. They now only answer "did this run's write land", and discovery vetoes them. Replacing them with discovery outright changes the vlt-withheld and earlier-grant-token cases and needs its own pass.rollbackrefuses as contested. That is the copy-source policy (B16), not decided here. A takeover could instead be checked in the takeover preflight and kept vendored, likeredirect_requirements_takeover_unreachable.bundle installlocks it, so under a cap it spends a slot on each run until then.hosted/engine.rs,CLI_CONTRACT.md), Make the vendored-to-hosted takeover atomic #1039 and Stop CI gates passing on missing paths, unverified agent patches and unreported hosted pins #1029 (scan/hosted.rs,hosted/engine.rs,hosted/memory/mod.rs), Decide vendored-entry liveness through one discovery verdict #1050 and Stop VEX attesting over yarn PnP, pnpm bundled and deno.lock copies #1033 (vex/discover/{mod,cargo,npm}.rs,pypi_requirements.rs). The ProjectView migration of discovery should land before Decide vendored-entry liveness through one discovery verdict #1050 and Stop VEX attesting over yarn PnP, pnpm bundled and deno.lock copies #1033 rebase, or after them, rather than both re-routing the same reads. Thelock_inventory/pnpm.rschange is a mechanical move toProjectView, not a pnpm fix. This PR keeps itsCLI_CONTRACT.mdedits away from Fix PyPI hosted takeover un-vendoring before refusal (#723, #945) #946's hunks.Fixes #567
Fixes #260
🤖 Generated with Claude Code
Note
Medium Risk
Changes hosted scan rewrite, rollout budgeting, and lock acquisition in ways that alter which patches get written and how caps apply; behavior is fail-closed with broad regression coverage but affects production lockfile edits.
Overview
Hosted mode now treats lockfile discovery as the single source of truth for whether a patch is pinned, replacing substring / uuid-mention heuristics in rollout recording, in-memory scans, and rewrite confirmation.
Before writing redirects, the hosted engine runs an attribution gate: it simulates the post-rewrite project (via
DiskSnapshot::overlayor in-memory writes) and drops any candidate whose pin would be contested wiring forvex/rollback/remove/vendor. Those show up asredirect.skipped[]withredirect_unattributableand no lockfile changes (exit 0). Vendored→hosted takeovers stay exempt viatakeover_uuidsso a wet run cannot strand a package on the unpatched registry after reverting vendor state.Rollout
--max-new-patchesno longer treats stale uuid mentions (e.g. inpackage.jsonmetadata) as “already pinned”; only attributableHostedPin::discoverresults count. Foreign patch-server origins from grant URLs are discovered before the apply lock is taken (fixing late-lock races). Lockless NuGet/Cargo pins still write but emitredirect_pin_locklessand clearer contested-wiring remedies.Discovery and requirements/Rush paths are refactored to read through
ProjectView/discover_patched_refs_view, with CLI discovery origins centralized onrollback::patch_server_origins.Reviewed by Cursor Bugbot for commit 5f0b71c. Configure here.
erification rules.
Generated by Claude Code