From 717838eb802a1f224f1b9a97c2593cb5c23a9d2b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 08:28:33 +0000 Subject: [PATCH 1/4] Start fix for #436, #445 Assisted-by: Claude Code:claude-opus-5-5 From 066a68f174c55868a65dda1902a8f60895d92910 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 08:49:28 +0000 Subject: [PATCH 2/4] Test global runs leave the project's state alone Regression tests for #436 and #445: get/scan refuse hosted and vendored mode under -g, rollback/remove -g leave a hosted or vendored project's wiring and ledgers alone, and apply/scan -g patch the global copy of a purl the project vendors. All but the control fail on main. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/global_scope_project_state.rs | 547 ++++++++++++++++++ 1 file changed, 547 insertions(+) create mode 100644 crates/socket-patch-cli/tests/global_scope_project_state.rs diff --git a/crates/socket-patch-cli/tests/global_scope_project_state.rs b/crates/socket-patch-cli/tests/global_scope_project_state.rs new file mode 100644 index 00000000..ff0c1559 --- /dev/null +++ b/crates/socket-patch-cli/tests/global_scope_project_state.rs @@ -0,0 +1,547 @@ +//! Global scope (`--global` / `--global-prefix`) never touches the `--cwd` +//! project's own hosted or vendored state (#436, #445). +//! +//! Global installs have no project lockfile, so a global run that starts +//! inside a project must leave that project's lockfile pins, vendored +//! wiring and vendor ledger alone, and the project's vendor ledger must not +//! decide ownership of a global copy: +//! +//! 1. `get` and `scan` refuse `--mode hosted|vendored` under global scope +//! (usage error, exit 2) instead of rewiring the project (#436); +//! 2. `rollback -g` and `remove -g` leave a hosted project's pins (and its +//! pre-v5 hosted ledger) as they were (#445); +//! 3. `rollback -g` and `remove -g` leave a vendored project's wiring, +//! artifact and ledger as they were (#445); +//! 4. `apply -g` and `scan -g --mode agent` patch the global copy of a purl +//! the project vendors (#445, the reverse direction). +//! +//! Binary-driven, `SOCKET_*`-scrubbed child processes (`common::run`). +//! Everything is offline except `scan`, which talks to a wiremock API. + +#[path = "prebuilt_common/mod.rs"] +mod prebuilt_common; + +#[path = "common/mod.rs"] +mod common; + +use std::path::{Path, PathBuf}; + +use serde_json::{json, Value}; +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +use common::git_sha256; + +const PATCH_HOST: &str = "http://patch.test"; + +fn run(root: &Path, args: &[&str]) -> (i32, String, String) { + common::run_with_env(root, args, &[("SOCKET_PATCH_SERVER_URL", PATCH_HOST)]) +} + +fn parse(stdout: &str, stderr: &str) -> Value { + serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!("expected a JSON envelope: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}") + }) +} + +/// An empty global prefix: a global run has nothing of its own to touch. +fn empty_prefix() -> tempfile::TempDir { + tempfile::tempdir().expect("tempdir") +} + +// ═══════════════════════ 1. get / scan mode guard ═══════════════════════ + +/// `get -g --mode hosted|vendored` (and `--global-prefix`) is a usage error +/// before any network, and leaves the project's lockfile alone. +#[test] +fn get_refuses_project_modes_under_global_scope() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("yarn.lock"), "# yarn lockfile v1\n").unwrap(); + let prefix = empty_prefix(); + let prefix = prefix.path().to_str().unwrap(); + for mode in ["hosted", "vendored"] { + for (scope, flag) in [ + (vec!["--global"], "--global"), + (vec!["--global-prefix", prefix], "--global-prefix"), + ] { + let mut args = vec!["get", "pkg:npm/left-pad@1.3.0", "--yes", "--mode", mode]; + args.extend(scope.iter().copied()); + let (code, stdout, stderr) = run(tmp.path(), &args); + assert_eq!(code, 2, "{args:?}: stdout={stdout:?} stderr={stderr:?}"); + let expected = format!( + "Error: {flag} cannot be used with --mode {mode}: global installs have no \ + project lockfile to" + ); + assert!(stderr.starts_with(&expected), "{args:?}: {stderr:?}"); + assert!(stdout.is_empty(), "{args:?}: {stdout:?}"); + + args.push("--json"); + let (code, stdout, _) = run(tmp.path(), &args); + assert_eq!(code, 2, "{args:?}"); + let v = parse(&stdout, ""); + assert_eq!(v["status"], "error", "{v}"); + assert!( + v["error"] + .as_str() + .unwrap() + .starts_with(&expected["Error: ".len()..]), + "{v}" + ); + } + } + assert_eq!( + std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(), + "# yarn lockfile v1\n" + ); + assert!(!tmp.path().join(".socket").exists(), "nothing written"); +} + +/// `scan -g --mode vendored` (and the hidden `--vendor` spelling) is a +/// usage error like `--mode hosted` already is. +#[test] +fn scan_refuses_vendored_mode_under_global_scope() { + let tmp = tempfile::tempdir().unwrap(); + let prefix = empty_prefix(); + let prefix = prefix.path().to_str().unwrap(); + for (args, flag) in [ + (vec!["scan", "--mode", "vendored", "--global"], "--global"), + (vec!["scan", "--vendor", "--global"], "--global"), + ( + vec!["scan", "--mode", "vendored", "--global-prefix", prefix], + "--global-prefix", + ), + ] { + let (code, stdout, stderr) = run(tmp.path(), &args); + assert_eq!(code, 2, "{args:?}: stdout={stdout:?} stderr={stderr:?}"); + assert!( + stderr.starts_with(&format!( + "Error: {flag} cannot be used with --mode vendored: global installs have no \ + project lockfile to wire vendored artifacts into" + )), + "{args:?}: {stderr:?}" + ); + assert!(stdout.is_empty(), "{args:?}: {stdout:?}"); + } + assert!(!tmp.path().join(".socket").exists(), "nothing written"); +} + +// ═══════════════════ 2. hosted project under rollback/remove -g ═══════════════════ + +const HOSTED_PURL: &str = "pkg:pypi/requests@2.31.0"; +const WIRED_LINE: &str = "requests @ http://patch.test/patch/pypi/requests/2.31.0/22222222-2222-4222-8222-222222222222/a1a1a1a1-a1a1-4a1a-8a1a-a1a1a1a1a1a1/requests-2.31.0-py3-none-any.whl --hash=sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn hosted_requirements() -> String { + format!("flask==2.0.1\n{WIRED_LINE}\n") +} + +/// A hosted-wired requirements.txt, a pre-v5 hosted ledger and an empty +/// manifest (so a global run gets past the "Manifest not found" check and +/// reaches its legs). +fn hosted_project() -> tempfile::TempDir { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("requirements.txt"), hosted_requirements()).unwrap(); + std::fs::create_dir_all(tmp.path().join(".socket/vendor")).unwrap(); + std::fs::write( + tmp.path().join(".socket/manifest.json"), + "{\n \"patches\": {}\n}\n", + ) + .unwrap(); + std::fs::write( + tmp.path().join(".socket/vendor/redirect-state.json"), + serde_json::to_vec_pretty(&json!({ "version": 1, "records": {}, "edits": [] })).unwrap(), + ) + .unwrap(); + tmp +} + +fn assert_hosted_untouched(root: &Path, what: &str) { + assert_eq!( + std::fs::read_to_string(root.join("requirements.txt")).unwrap(), + hosted_requirements(), + "{what}: the project's hosted pin must stay wired" + ); + assert!( + root.join(".socket/vendor/redirect-state.json").is_file(), + "{what}: the project's pre-v5 hosted ledger must not be retired" + ); +} + +/// Control: a project-scoped rollback does restore the pin (so the global +/// assertions below are not vacuous). +#[test] +fn project_rollback_restores_the_hosted_pin() { + let tmp = hosted_project(); + let (code, stdout, stderr) = run(tmp.path(), &["rollback", "--json", "--yes", "--offline"]); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + assert_eq!( + std::fs::read_to_string(tmp.path().join("requirements.txt")).unwrap(), + "flask==2.0.1\nrequests==2.31.0\n" + ); +} + +#[test] +fn global_rollback_leaves_hosted_project_pins() { + let prefix = empty_prefix(); + let prefix = prefix.path().to_str().unwrap(); + for scope in [ + vec!["--global-prefix", prefix], + vec!["--global", "--global-prefix", prefix], + ] { + let tmp = hosted_project(); + let mut args = vec!["rollback", "--json", "--yes", "--offline"]; + args.extend(scope.iter().copied()); + let (code, stdout, stderr) = run(tmp.path(), &args); + assert_eq!(code, 0, "{args:?}: stdout={stdout}\nstderr={stderr}"); + let v = parse(&stdout, &stderr); + assert_eq!(v["status"], "success", "{v}"); + assert_eq!(v["hosted"]["reverted"], json!([]), "{args:?}: {v}"); + assert_hosted_untouched(tmp.path(), &format!("{args:?}")); + } +} + +#[test] +fn global_remove_leaves_hosted_project_pins() { + let prefix = empty_prefix(); + let prefix = prefix.path().to_str().unwrap(); + let tmp = hosted_project(); + let args = [ + "remove", + HOSTED_PURL, + "--json", + "--yes", + "--offline", + "--global-prefix", + prefix, + ]; + let (code, stdout, stderr) = run(tmp.path(), &args); + // The global scope holds no patch for the purl: not found. + assert_eq!(code, 1, "stdout={stdout}\nstderr={stderr}"); + assert_hosted_untouched(tmp.path(), "remove --global-prefix"); +} + +// ═══════════════════ 3. vendored project under rollback/remove -g ═══════════════════ + +const V_UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; +const V_PURL: &str = "pkg:npm/left-pad@1.3.0"; +const ORIG_INDEX: &[u8] = b"module.exports = () => 'orig';\n"; +const PATCHED_INDEX: &[u8] = b"module.exports = () => 'patched';\n"; + +/// A vendored npm project: `vendor` (offline, the prebuilt artifact +/// service) wired `left-pad` into `.socket/vendor/` and recorded it in the +/// ledger; the manifest keeps its record. +struct VendoredProject { + tmp: tempfile::TempDir, + wired_lock: Vec, + ledger: Vec, +} + +impl VendoredProject { + fn root(&self) -> &Path { + self.tmp.path() + } + fn tgz(&self) -> PathBuf { + self.root() + .join(format!(".socket/vendor/npm/{V_UUID}/left-pad-1.3.0.tgz")) + } + fn assert_untouched(&self, what: &str) { + assert_eq!( + std::fs::read(self.root().join("package-lock.json")).unwrap(), + self.wired_lock, + "{what}: the project's vendored wiring must stay" + ); + assert!( + self.tgz().is_file(), + "{what}: the vendored artifact must stay" + ); + assert_eq!( + std::fs::read(self.root().join(".socket/vendor/state.json")).unwrap(), + self.ledger, + "{what}: the vendor ledger must stay byte-identical" + ); + assert_eq!( + std::fs::read(self.root().join("node_modules/left-pad/index.js")).unwrap(), + ORIG_INDEX, + "{what}: the project's installed copy must stay" + ); + } + fn manifest(&self) -> Value { + serde_json::from_slice(&std::fs::read(self.root().join(".socket/manifest.json")).unwrap()) + .unwrap() + } +} + +fn write_left_pad(dir: &Path, index: &[u8]) { + std::fs::create_dir_all(dir).unwrap(); + std::fs::write( + dir.join("package.json"), + br#"{"name":"left-pad","version":"1.3.0"}"#, + ) + .unwrap(); + std::fs::write(dir.join("index.js"), index).unwrap(); +} + +fn vendored_project() -> VendoredProject { + let tmp = tempfile::tempdir().expect("tempdir"); + let root = tmp.path(); + write_left_pad(&root.join("node_modules/left-pad"), ORIG_INDEX); + std::fs::write( + root.join("package.json"), + br#"{"name":"fixture","version":"1.0.0","private":true}"#, + ) + .unwrap(); + let lock = json!({ + "name": "fixture", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fixture", + "version": "1.0.0", + "dependencies": { "left-pad": "^1.3.0" } + }, + "node_modules/left-pad": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "integrity": "sha512-orig==", + "license": "WTFPL" + } + } + }); + let mut original_lock = serde_json::to_vec_pretty(&lock).unwrap(); + original_lock.push(b'\n'); + std::fs::write(root.join("package-lock.json"), &original_lock).unwrap(); + + let after_hash = git_sha256(PATCHED_INDEX); + let manifest = json!({ + "patches": { + V_PURL: { + "uuid": V_UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": git_sha256(ORIG_INDEX), + "afterHash": after_hash + } + }, + "vulnerabilities": {}, + "description": "synthetic global-scope patch", + "license": "MIT", + "tier": "free" + } + } + }); + let socket = root.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_vec_pretty(&manifest).unwrap(), + ) + .unwrap(); + std::fs::write(socket.join("blobs").join(&after_hash), PATCHED_INDEX).unwrap(); + std::fs::write( + socket.join("blobs").join(git_sha256(ORIG_INDEX)), + ORIG_INDEX, + ) + .unwrap(); + + let service = prebuilt_common::Server::project(root); + let (code, stdout, stderr) = common::run_with_env( + root, + &["vendor", "--json", "--silent", "--lock-timeout", "5"], + &[("SOCKET_VENDOR_URL", &service.uri)], + ); + assert_eq!( + code, 0, + "fixture vendor: stdout=\n{stdout}\nstderr=\n{stderr}" + ); + let wired_lock = std::fs::read(root.join("package-lock.json")).unwrap(); + assert_ne!(wired_lock, original_lock, "sanity: vendor rewired the lock"); + let ledger = std::fs::read(root.join(".socket/vendor/state.json")).unwrap(); + let project = VendoredProject { + tmp, + wired_lock, + ledger, + }; + assert!(project.tgz().is_file(), "sanity: artifact written"); + project +} + +#[test] +fn global_rollback_leaves_vendored_project_state() { + let project = vendored_project(); + let prefix = empty_prefix(); + let (code, stdout, stderr) = run( + project.root(), + &[ + "rollback", + "--json", + "--yes", + "--offline", + "--global-prefix", + prefix.path().to_str().unwrap(), + ], + ); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + let v = parse(&stdout, &stderr); + assert_eq!(v["vendoredReverted"], json!([]), "{v}"); + project.assert_untouched("rollback --global-prefix"); + assert!( + project.manifest()["patches"].get(V_PURL).is_some(), + "the project's vendored record must stay in the manifest" + ); +} + +#[test] +fn global_remove_leaves_vendored_project_state() { + let project = vendored_project(); + let prefix = empty_prefix(); + let (code, stdout, stderr) = run( + project.root(), + &[ + "remove", + V_PURL, + "--json", + "--yes", + "--offline", + "--global-prefix", + prefix.path().to_str().unwrap(), + ], + ); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + project.assert_untouched("remove --global-prefix"); +} + +// ═══════════════════ 4. a vendored purl's global copy is patched ═══════════════════ + +/// `apply --global-prefix` patches the global copy of a purl the project +/// vendors: the project's ledger owns the project's copy, not the global +/// one. +#[test] +fn global_apply_patches_a_purl_the_project_vendors() { + let project = vendored_project(); + let prefix = empty_prefix(); + write_left_pad(&prefix.path().join("left-pad"), ORIG_INDEX); + let (code, stdout, stderr) = run( + project.root(), + &[ + "apply", + "--json", + "--offline", + "--global-prefix", + prefix.path().to_str().unwrap(), + ], + ); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + assert_eq!( + std::fs::read(prefix.path().join("left-pad/index.js")).unwrap(), + PATCHED_INDEX, + "the global copy must be patched; stdout={stdout}" + ); + project.assert_untouched("apply --global-prefix"); +} + +const ORG: &str = "test-org"; + +async fn mount_api(mock: &MockServer) { + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "packages": [{ + "purl": V_PURL, + "patches": [{ + "uuid": V_UUID, "purl": V_PURL, "tier": "free", + "cveIds": [], "ghsaIds": ["GHSA-left-pad-0"], + "severity": "high", "title": "left-pad", + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(mock) + .await; + let vulns = json!({ + "GHSA-left-pad-0": { "cves": [], "summary": "s", "severity": "high", "description": "d" } + }); + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.*left-pad(%40|@)1\\.3\\.0$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "patches": [{ + "uuid": V_UUID, "purl": V_PURL, "publishedAt": "2026-01-01T00:00:00Z", + "description": "left-pad", "license": "MIT", "tier": "free", + "vulnerabilities": vulns, + }], + "canAccessPaidPatches": false, + }))) + .mount(mock) + .await; + use base64::Engine; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{V_UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "uuid": V_UUID, "purl": V_PURL, "publishedAt": "2026-01-01T00:00:00Z", + "files": { "package/index.js": { + "beforeHash": git_sha256(ORIG_INDEX), + "afterHash": git_sha256(PATCHED_INDEX), + "blobContent": base64::engine::general_purpose::STANDARD.encode(PATCHED_INDEX), + }}, + "vulnerabilities": vulns, + "description": "left-pad", "license": "MIT", "tier": "free", + }))) + .mount(mock) + .await; +} + +/// `scan -g --mode agent` patches the global copy of a purl the project +/// vendors instead of skipping it as `vendored_ownership_retained`. +#[tokio::test] +async fn global_agent_scan_patches_a_purl_the_project_vendors() { + let project = vendored_project(); + // v5 vendored mode is manifest-free: the ledger alone records the + // patch, so nothing marks the global copy as already patched. + std::fs::write( + project.root().join(".socket/manifest.json"), + "{\n \"patches\": {}\n}\n", + ) + .unwrap(); + let prefix = empty_prefix(); + write_left_pad(&prefix.path().join("left-pad"), ORIG_INDEX); + let mock = MockServer::start().await; + mount_api(&mock).await; + let uri = mock.uri(); + let (code, stdout, stderr) = run( + project.root(), + &[ + "scan", + "--json", + "--yes", + "--mode", + "agent", + "--global-prefix", + prefix.path().to_str().unwrap(), + "--api-url", + &uri, + "--api-token", + "fake-token", + "--org", + ORG, + ], + ); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + let v = parse(&stdout, &stderr); + let codes: Vec<&str> = v["warnings"] + .as_array() + .map(|w| w.iter().filter_map(|w| w["code"].as_str()).collect()) + .unwrap_or_default(); + assert!( + !codes.contains(&"vendored_ownership_retained"), + "the project's ledger must not own the global copy: {v}" + ); + assert_eq!( + std::fs::read(prefix.path().join("left-pad/index.js")).unwrap(), + PATCHED_INDEX, + "the global copy must be patched: {v}" + ); + project.assert_untouched("scan --global-prefix --mode agent"); +} From 8f33a5d52a94e1d78b5241ea25a0c0bde6e0877e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 08:49:28 +0000 Subject: [PATCH 3/4] Keep -g runs off the project's hosted/vendored state Global installs have no project lockfile, but a global run started inside a project still touched that project: get -g --mode hosted|vendored and scan -g --mode vendored rewired it, rollback -g and remove -g unwound its hosted pins and vendored wiring (on vlt deleting the installed package), and the project's vendor ledger made apply -g and scan -g skip the global copy of a purl the project vendors. One rule now decides this (project_state_in_scope): under global scope get and scan refuse hosted and vendored mode with one shared usage error, rollback and remove skip their hosted and vendored legs and the pre-v5 ledger retirement, and the ledger no longer owns global copies. A global rollback still keeps the project's vendored manifest records. Fixes #436 Fixes #445 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/src/commands/apply.rs | 9 ++- crates/socket-patch-cli/src/commands/get.rs | 6 ++ crates/socket-patch-cli/src/commands/mod.rs | 38 +++++++++++++ .../socket-patch-cli/src/commands/remove.rs | 25 ++++++-- .../socket-patch-cli/src/commands/rollback.rs | 57 ++++++++++++++++--- .../socket-patch-cli/src/commands/scan/mod.rs | 34 +++++------ 6 files changed, 138 insertions(+), 31 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 761d830c..aa8aa695 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -1714,7 +1714,14 @@ async fn apply_patches_inner( // by ledger key, resolved base purl, or qualifier-stripped key so // release-variant manifest keys (pypi `?artifact_id=`…) hit too; // unreadable state degrades to "nothing vendored" (fail-open). - let vendored_purls = socket_patch_core::vendor::vendored_purl_keys(&args.common.cwd).await; + // The ledger owns the PROJECT's copies only: a global apply restores + // and patches the global copy even when the cwd project vendors the + // same purl (see `project_state_in_scope`). + let vendored_purls = if crate::commands::project_state_in_scope(&args.common) { + socket_patch_core::vendor::vendored_purl_keys(&args.common.cwd).await + } else { + Default::default() + }; let is_vendored = |p: &str| purl_keys_cover(&vendored_purls, p); let (mut results, mut matched_manifest_purls, vendored_bases) = synthesize_vendor_owned_results(&target_manifest_purls, &vendored_purls); diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index ae9598e3..e34b77d0 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -2527,6 +2527,12 @@ pub async fn run(args: GetArgs) -> i32 { } else { super::scan::ScanMode::Hosted }); + // Global installs have no project lockfile: an explicit hosted or + // vendored mode would rewire the cwd project, not the global copy. + if let Some(conflict) = super::global_mode_conflict(&args.common, mode) { + report_error(args.common.json, conflict); + return 2; + } if args.save_only && mode != super::scan::ScanMode::Agent { report_error( args.common.json, diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index 7099e4a6..798ab487 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -34,6 +34,44 @@ pub(crate) const HOSTED_MODE_LABEL: &str = "hosted"; /// `record`, so the ledger is the only place those records live. pub(crate) const VENDORED_MODE_LABEL: &str = "vendored"; +/// Whether the run's target includes the `--cwd` project's own +/// lockfile-backed state: its hosted pins and its vendor ledger. Global +/// scope (`--global` / `--global-prefix`) targets globally installed +/// packages, which have no project lockfile (CLI_CONTRACT.md, Mode +/// resolution). The project a global run happens to start in is not its +/// target, so that project's hosted and vendored state is never rewired, +/// unwound, or consulted for ownership of a global copy. +pub(crate) fn project_state_in_scope(common: &crate::args::GlobalArgs) -> bool { + !common.is_global() +} + +/// The usage error for a mode that rewires the project (`hosted`, +/// `vendored`) under global scope, or `None` when `mode` is allowed. +/// Shared by `scan` and `get` so both refuse the same combinations with +/// the same wording. +pub(crate) fn global_mode_conflict( + common: &crate::args::GlobalArgs, + mode: scan::ScanMode, +) -> Option { + if project_state_in_scope(common) { + return None; + } + let why = match mode { + scan::ScanMode::Agent => return None, + scan::ScanMode::Hosted => "redirect", + scan::ScanMode::Vendored => "wire vendored artifacts into", + }; + Some(format!( + "{} cannot be used with --mode {}: global installs have no project lockfile to {why}", + if common.global { + "--global" + } else { + "--global-prefix" + }, + mode.cli_name(), + )) +} + /// Lockfile discovery of `root` (core `vex::discover`): the hosted and /// vendored patch references its lockfiles and configs wire, with hosted /// references counted on Socket's public patch server plus the operator's diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index 3287aa81..0d4be4bb 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -345,13 +345,24 @@ pub async fn run(args: RemoveArgs) -> i32 { // must not see `.socket/` created and pruned again). The vendor ledger // is loaded under the lock below; the hosted pins come from read-only // lockfile discovery (the restore re-reads every file under the lock). + // + // Under global scope the project's hosted pins and vendor ledger are + // not this run's to unwind (see `project_state_in_scope`): a global + // remove restores the global copies and drops their manifest records + // only. + let project_state = crate::commands::project_state_in_scope(&args.common); let manifest_missing = tokio::fs::metadata(&manifest_path).await.is_err(); - let hosted_inventory = crate::commands::hosted_inventory(&args.common, cwd).await; + let hosted_inventory = if project_state { + crate::commands::hosted_inventory(&args.common, cwd).await + } else { + Default::default() + }; let hosted_pins: Vec = hosted_inventory.pins.clone(); if manifest_missing { - let vendor_ledger_exists = tokio::fs::metadata(cwd.join(VENDOR_STATE_REL)) - .await - .is_ok(); + let vendor_ledger_exists = project_state + && tokio::fs::metadata(cwd.join(VENDOR_STATE_REL)) + .await + .is_ok(); if !vendor_ledger_exists && hosted_pins.is_empty() { // Contested hosted wiring is still hosted state: name it // instead of reporting a bare project. @@ -440,7 +451,11 @@ pub async fn run(args: RemoveArgs) -> i32 { // the vendored leg. An unreadable ledger degrades to "nothing vendored" // for the rollback and fails closed at the vendored leg — exactly where // the run is about to mutate vendored state. - let vendor_state_result = load_state(cwd).await; + let vendor_state_result = if project_state { + load_state(cwd).await + } else { + Ok(VendorState::default()) + }; if matching.is_empty() { // Ledger-only entries (vendored mode keeps no manifest record) — diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 05a00c7c..5f419103 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -1043,7 +1043,10 @@ pub(crate) async fn retire_legacy_redirect_ledger(common: &GlobalArgs) -> Option let path = common .cwd .join(socket_patch_core::patch::redirect::REDIRECT_STATE_REL); - if common.dry_run || tokio::fs::symlink_metadata(&path).await.is_err() { + if common.dry_run + || !crate::commands::project_state_in_scope(common) + || tokio::fs::symlink_metadata(&path).await.is_err() + { return None; } let remaining = crate::commands::discover_wiring(common, &common.cwd).await; @@ -1111,13 +1114,23 @@ pub async fn run(args: RollbackArgs) -> i32 { // themselves are LOADED UNDER the apply lock below: this run persists // mutated clones of the ledgers, so a pre-lock snapshot could clobber // a concurrent run's writes with stale state. + // + // Under global scope the project's hosted pins and vendor ledger are + // not this run's to unwind (see `project_state_in_scope`): a global + // rollback restores the global copies and their manifest records only. + let project_state = crate::commands::project_state_in_scope(&args.common); let manifest_missing = tokio::fs::metadata(&manifest_path).await.is_err(); - let vendor_ledger_exists = tokio::fs::metadata(cwd.join(".socket/vendor/state.json")) - .await - .is_ok(); + let vendor_ledger_exists = project_state + && tokio::fs::metadata(cwd.join(".socket/vendor/state.json")) + .await + .is_ok(); // The hosted pins the lockfiles wire (read-only discovery; the restore // re-reads every file under the lock before it writes). - let hosted_inventory = crate::commands::hosted_inventory(&args.common, &cwd).await; + let hosted_inventory = if project_state { + crate::commands::hosted_inventory(&args.common, &cwd).await + } else { + Default::default() + }; let hosted_pins: Vec = hosted_inventory.pins.clone(); if manifest_missing && !vendor_ledger_exists && hosted_pins.is_empty() { @@ -1131,7 +1144,7 @@ pub async fn run(args: RollbackArgs) -> i32 { // so there is nothing to restore — retire the stale file (a wet run // only) instead of failing on the missing manifest. let legacy = cwd.join(socket_patch_core::patch::redirect::REDIRECT_STATE_REL); - if tokio::fs::symlink_metadata(&legacy).await.is_ok() { + if project_state && tokio::fs::symlink_metadata(&legacy).await.is_ok() { let warning = retire_legacy_redirect_ledger(&args.common).await; if args.common.json { println!( @@ -1167,7 +1180,11 @@ pub async fn run(args: RollbackArgs) -> i32 { // with lockfiles still consuming `.socket/vendor/` artifacts: the // ledger holds the pre-vendor originals, so it must come back from // version control first.) - let wired = crate::commands::vendored_backend::repair::scan_vendor_references(&cwd).await; + let wired = if project_state { + crate::commands::vendored_backend::repair::scan_vendor_references(&cwd).await + } else { + Default::default() + }; if !wired.is_empty() { emit_rollback_error( args.common.json, @@ -1213,7 +1230,22 @@ pub async fn run(args: RollbackArgs) -> i32 { // Load the state stores UNDER the lock (see the discovery note above), // each exactly once: the agent leg below receives the manifest and the // vendor-ownership key set instead of re-reading them. - let vendor_state_result = socket_patch_core::vendor::load_state(&cwd).await; + // + // Under global scope the ledger is read only to keep the project's + // vendored manifest records (see the cleanup below): no vendored leg + // runs, and the ledger does not own the global copies, so the in-place + // leg restores them. + let loaded_vendor_state = socket_patch_core::vendor::load_state(&cwd).await; + let project_vendored_keys: HashSet = loaded_vendor_state + .as_ref() + .map(VendorState::purl_keys) + .unwrap_or_default(); + let ledger_unreadable = loaded_vendor_state.is_err(); + let vendor_state_result = if project_state { + loaded_vendor_state + } else { + Ok(VendorState::default()) + }; let vendor_corrupt = vendor_state_result.is_err(); // An unreadable ledger degrades to "nothing vendored" for the in-place // leg (its own containment is the `vendor_state_unreadable` exit below). @@ -1558,7 +1590,11 @@ pub async fn run(args: RollbackArgs) -> i32 { .filter(|r| !r.success) .map(|r| r.package_key.clone()) .collect(); - let cleanup_allowed = !args.preserve_state && !aborted && !vendor_corrupt; + // A global run keeps the project's vendored records too: their + // vendored state is not unwound, so dropping them would hand a + // later `vendor` reconcile a revert with no backing record. An + // unreadable ledger leaves that ownership unknowable either way. + let cleanup_allowed = !args.preserve_state && !aborted && !ledger_unreadable; // A vendor-owned manifest purl is removable only when its // ledger entry was cleanly reverted this run (drift-keeps and // failures keep the record; the matching mirrors the @@ -1588,6 +1624,9 @@ pub async fn run(args: RollbackArgs) -> i32 { if failed_purls.contains(*purl) { return false; } + if !project_state && purl_keys_cover(&project_vendored_keys, purl) { + return false; + } if vendored_excluded.contains(purl) { return vendored_reverted_ok(purl); } diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 26a1282d..479814ff 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -234,20 +234,13 @@ pub fn resolve_mode_flags(args: &mut ScanArgs) -> Result<(), String> { // stays report-only (neither has a project lockfile to rewire). args.mode = Some(ScanMode::Hosted); } - if args.mode == Some(ScanMode::Hosted) - && args.common.is_global() + // Global installs have no project lockfile: hosted and vendored mode + // would rewire the cwd project instead of the global copy. + if let Some(conflict) = args + .mode + .and_then(|mode| crate::commands::global_mode_conflict(&args.common, mode)) { - // Global installs have no project lockfile to repoint: the hosted - // flow would "redirect 0 packages" and exit 0, a silent no-op. - return Err(format!( - "{} cannot be used with --mode hosted: global installs have no project \ - lockfile to redirect", - if args.common.global { - "--global" - } else { - "--global-prefix" - }, - )); + return Err(conflict); } Ok(()) } @@ -1653,6 +1646,15 @@ async fn run_scan( .as_ref() .map(VendorState::purl_keys) .unwrap_or_default(); + // The ledger owns and records the PROJECT's copies only: a global + // scan's agent leg patches the global copy even when the cwd project + // vendors the same purl (see `project_state_in_scope`). + let project_state = crate::commands::project_state_in_scope(&args.common); + let vendor_owned_purls: HashSet = if project_state { + vendored_purls.clone() + } else { + HashSet::new() + }; // Read existing manifest once for update detection. let existing_manifest = ctx.ledgers().await.manifest; @@ -1676,7 +1678,7 @@ async fn run_scan( .collect(); let update_manifest = merge_ledger_records_for_updates( existing_manifest, - vendor_state.as_ref().ok(), + vendor_state.as_ref().ok().filter(|_| project_state), &hosted_pins, ); policy.set_recorded(update_manifest.as_deref()); @@ -2245,7 +2247,7 @@ async fn run_scan( skip_records: vendored_records, vendored_purls: vendored_skip_purls, .. - } = partition_agent_selection(writers_of(&rows), &vendored_purls, &lockfile_only); + } = partition_agent_selection(writers_of(&rows), &vendor_owned_purls, &lockfile_only); let selected = plan_kept_rows(&mut stage, rows, kept); if dry { @@ -2665,7 +2667,7 @@ async fn run_scan( let selected = if vendor { selected } else { - let split = partition_agent_selection(selected, &vendored_purls, &lockfile_only); + let split = partition_agent_selection(selected, &vendor_owned_purls, &lockfile_only); if !silent { for purl in &split.vendored_purls { open_paragraph(&mut skip_paragraph); From 92c71adf9fbaaff2825745aecdd110878bf66392 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 08:49:28 +0000 Subject: [PATCH 4/4] Document that -g leaves the project's state alone CLI_CONTRACT.md: get and scan refuse --mode hosted|vendored under global scope, and rollback, remove, apply and agent scans under global scope neither unwind nor defer to the project's hosted/vendored state. Refs #436, #445 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index bcbf0fa6..35a107d8 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -102,7 +102,7 @@ Beyond the globals above, each subcommand defines a small set of local arguments | `scan` | `--no-socket-yml` | `SOCKET_NO_SOCKET_YML` | (v5.0) Ignore the repository's socket.yml patch policy (its `patches` block and `projectIgnorePaths`) for this run; the built-in test/fixture ignores still apply. The `policy` block reports `source: "bypassed"`. See "socket.yml patch policy". | | `scan` | `--min-severity ` | `SOCKET_MIN_SEVERITY` | (v5.0) Severity floor for the patch a package may receive (worst advisory severity; unknown severity is skipped whenever a floor is set). Beats `patches.minSeverity`; the flag beats the env; `none` lifts the floor. A malformed value is exit 2. | | `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`); `--mode ` | `SOCKET_SAVE_ONLY` | 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) | +| `get` | positional `identifier`; `--id` / `--cve` / `--ghsa` / `--package` (`-p`); `--save-only` (alias `--no-apply`); `--mode ` | `SOCKET_SAVE_ONLY` | 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 `--mode hosted\|vendored` with `--global`/`--global-prefix` is a usage error (exit 2, scan's wording: global installs have no project lockfile). 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); `--preserve-state` (v5.0) | `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 | @@ -124,7 +124,9 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc ### Scan modes (v5.0) -**Mode resolution (`resolve_mode_flags`, MAJOR in v5.0).** `--mode`, or one of its legacy boolean spellings (`--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` with `--global`/`--global-prefix` is a usage error (exit 2: global installs have no project lockfile to redirect). +**Mode resolution (`resolve_mode_flags`, MAJOR in v5.0).** `--mode`, or one of its legacy boolean spellings (`--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 `--mode vendored` (or the hidden `--vendor`) with `--global`/`--global-prefix` is a usage error (exit 2: global installs have no project lockfile to redirect, or to wire vendored artifacts into); `get` enforces the same rule with the same wording. + +**Global scope never touches the project's state (v5.0).** A `--global`/`--global-prefix` run that starts inside a project acts on the global installs only. The `--cwd` project's hosted pins and vendor ledger are not its target: `rollback` and `remove` run no hosted or vendored leg (they restore the global copies and drop their manifest records; `rollback` keeps the manifest records of purls the project vendors), a pre-v5 hosted ledger is never retired, and the project's vendor ledger does not own the global copies, so `apply` and `scan --mode agent` patch the global copy of a purl the project vendors (no `vendored` skip, no `vendored_ownership_retained` warning). **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` (agent mode only — hosted/vendored `get` never prompts either, v5.0), `rollback`, `remove` and `--update` keep their prompts. @@ -819,7 +821,7 @@ worse, lets a warm cache silently serve unpatched bytes): A bare `rollback` (or a scoped one, for its scope) restores the SYSTEM to unpatched and cleans up the local state, in phases under one `apply.lock` acquisition: -1. **State discovery.** A missing manifest is no longer fatal when the vendor ledger or the lockfiles' hosted pins hold work (`rollback` runs manifest-less on hosted-only / vendored projects — every `scan`/`get --mode vendored` and v5 `scan --mode hosted` project is manifest-less). The **truly-empty** project — no manifest, no vendor ledger, no hosted pin — keeps the legacy "Manifest not found" exit 1 (JSON: the legacy `{status: "error", error: "Manifest not found", path}` shape), with one v5.0 exception: when a pre-v5 `.socket/vendor/redirect-state.json` is the only thing left, nothing pins it any more, so a wet run deletes it and exits 0 (human `Removed the pre-v5 hosted ledger .socket/vendor/redirect-state.json: no lockfile pins a hosted patch.`, `Would remove …` on `--dry-run`, which deletes nothing; JSON `{status: "success", rolledBack: 0, alreadyOriginal: 0, failed: 0, dryRun, warnings, legacyRedirectLedgerRemoved}` — a minimal envelope without the keys below; a failed delete is the `legacy_redirect_ledger_kept` warning, still exit 0). A project whose lockfiles still reference `.socket/vendor/` artifacts but whose vendor ledger is missing errors asking for `.socket/vendor/state.json` to be restored from version control first (v5.0: `repair` no longer reconstructs the ledger). **Corrupt-ledger containment**: an unreadable vendor ledger fails ONLY the legs that need it — the vendored leg, manifest cleanup, and GC are skipped fail-closed (`vendor_state_unreadable` warning) while the agent and hosted legs still run; it drives `partial_failure` exit 1, and an emergency restore is never blocked by it. When the ONLY state on disk is an unreadable vendor ledger, the run fails closed naming the store. A pre-v5 redirect ledger is never read by rollback (v4's `redirect_state_unreadable` is no longer emitted). +1. **State discovery.** A missing manifest is no longer fatal when the vendor ledger or the lockfiles' hosted pins hold work (`rollback` runs manifest-less on hosted-only / vendored projects — every `scan`/`get --mode vendored` and v5 `scan --mode hosted` project is manifest-less). The **truly-empty** project — no manifest, no vendor ledger, no hosted pin — keeps the legacy "Manifest not found" exit 1 (JSON: the legacy `{status: "error", error: "Manifest not found", path}` shape), with one v5.0 exception: when a pre-v5 `.socket/vendor/redirect-state.json` is the only thing left, nothing pins it any more, so a wet run deletes it and exits 0 (human `Removed the pre-v5 hosted ledger .socket/vendor/redirect-state.json: no lockfile pins a hosted patch.`, `Would remove …` on `--dry-run`, which deletes nothing; JSON `{status: "success", rolledBack: 0, alreadyOriginal: 0, failed: 0, dryRun, warnings, legacyRedirectLedgerRemoved}` — a minimal envelope without the keys below; a failed delete is the `legacy_redirect_ledger_kept` warning, still exit 0). A project whose lockfiles still reference `.socket/vendor/` artifacts but whose vendor ledger is missing errors asking for `.socket/vendor/state.json` to be restored from version control first (v5.0: `repair` no longer reconstructs the ledger). **Corrupt-ledger containment**: an unreadable vendor ledger fails ONLY the legs that need it — the vendored leg, manifest cleanup, and GC are skipped fail-closed (`vendor_state_unreadable` warning) while the agent and hosted legs still run; it drives `partial_failure` exit 1, and an emergency restore is never blocked by it. When the ONLY state on disk is an unreadable vendor ledger, the run fails closed naming the store. A pre-v5 redirect ledger is never read by rollback (v4's `redirect_state_unreadable` is no longer emitted). Under `--global`/`--global-prefix` the project's hosted pins and vendor ledger are not discovered at all, so the vendored and hosted legs below do not run (see "Global scope never touches the project's state"). 2. **Agent leg** — the existing in-place restore machinery, unchanged (v5.0 presentation: the human `No patches found in manifest` line prints only for an unscoped run with no work in ANY leg — a run whose work is all vendored/hosted stays quiet about the manifest): multi-copy restore, release-variant narrowing, the before-blob gate (+ on-demand download; a gate abort still exits 1 with per-package `missing_blob` failure results **and** skips manifest cleanup + GC entirely — nothing was restored, and the retry's revert data must survive), local-go redirect drop, and the `not_installed` exit-0 asymmetry verbatim. Vendor-owned purls are still excluded here (see the vendored-mode section) — they are handled by the next leg instead of being punted to other commands. 3. **Vendored leg** — each in-scope ledger entry (embedded-record entries included) is reverted through the vendor backends: lockfile wiring restored, artifact dir deleted (and its emptied `.socket/vendor//` husk pruned, v5.0), ledger entry dropped + persisted per purl (crash-consistent, like `vendor --revert`). A **drift-keep** (the backend refused a drifted lock) keeps the entry, the artifact, AND the manifest record (`vendoredKept`, exit 1 — the system is still patched); a failure is recorded and other entries proceed. 4. **Hosted leg** — each in-scope hosted pin is restored to its default upstream registry entry; see "Hosted unwind coverage" below. After a hosted leg with no failure, a wet run deletes a pre-v5 `redirect-state.json` once no lockfile pins a hosted patch any more (a failed delete is the `legacy_redirect_ledger_kept` warning). @@ -1529,7 +1531,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, an invalid or ambiguous socket.yml on `scan` (v5.0), etc.) | -| `2` | Usage error: clap parse failures (unknown flag/value, missing required arg, an unknown subcommand such as the removed `setup`) 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`) 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`, a `scan` PATH outside the repository root and a malformed `SOCKET_MIN_SEVERITY` or `SOCKET_MAX_NEW_PATCHES` (v5.0), `repair --offline --download-only`. `vex` also exits `2` on hard errors before document generation (see its tri-state table below). v5.0: `get`'s self-enforced conflicts exit `2` too (`--id`/`--cve`/`--ghsa`/`--package` multi-select, `--mode hosted\|vendored --save-only`, a malformed identifier for a forced `--id`/`--cve`/`--ghsa`) — previously `1` (MAJOR). The never-implemented `get --one-off` / `rollback --one-off` (and `SOCKET_ONE_OFF`) are removed in v5.0; `--one-off` is now an ordinary unknown-flag clap error. | +| `2` | Usage error: clap parse failures (unknown flag/value, missing required arg, an unknown subcommand such as the removed `setup`) 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`) and `--mode hosted\|vendored` with `--global`/`--global-prefix` (same enforcement point; `get` refuses the same combination); 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`, a `scan` PATH outside the repository root and a malformed `SOCKET_MIN_SEVERITY` or `SOCKET_MAX_NEW_PATCHES` (v5.0), `repair --offline --download-only`. `vex` also exits `2` on hard errors before document generation (see its tri-state table below). v5.0: `get`'s self-enforced conflicts exit `2` too (`--id`/`--cve`/`--ghsa`/`--package` multi-select, `--mode hosted\|vendored --save-only`, a malformed identifier for a forced `--id`/`--cve`/`--ghsa`) — previously `1` (MAJOR). The never-implemented `get --one-off` / `rollback --one-off` (and `SOCKET_ONE_OFF`) are removed in v5.0; `--one-off` is now an ordinary unknown-flag clap error. | `list` returns **`0`** for every project it can read, empty or not (**v5.0, BREAKING**: a project with no manifest and no ledger record — normal for hosted mode, which writes no manifest — used to exit `1` with `manifest_not_found`; it is now an empty list: `No patches in this project. Run \`socket-patch scan\`.` on stdout, and under `--json` the success envelope with `events: []`). Only an unreadable or invalid manifest (`manifest_unreadable` / `manifest_invalid`) exits `1`. 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`.