From 0842aca6e78dbaa4336aa8f0d8bffa3082e81603 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 18 Aug 2026 20:47:32 -0400 Subject: [PATCH 1/2] =?UTF-8?q?test(scan):=20RED=20=E2=80=94=20pin=20cross?= =?UTF-8?q?-mode=20visibility=20warnings=20(hosted/vendored=20retained,=20?= =?UTF-8?q?berry=20vendored-entry=20refusal)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Failing-first tests for three adversarially-verified silence gaps in the mode-conversion matrix (additive warnings only; no exit/status changes): * A — scan --mode agent over a live hosted redirect reports success with zero hint that yarn.lock still pins the patch server and the redirect ledger stays live: scan_invariants pins a top-level hosted_wiring_retained run warning (+ two silence guards: records-gone = lane-B pre-revert world, and registry-clean lock). * D — agent-mode apply buries vendor-owned skips deep in apply.patches[]: scan_invariants pins a top-level vendored_ownership_retained warning naming the purls and the real migration path. * E — the berry hosted rewriter refuses our OWN vendored file:./.socket/vendor/ entry under the generic unsupported_protocol code whose detail hardcodes '(workspace:/patch:/portal:/link:)' (file: not even listed) and names no remedy: redirect unit tests pin a distinct redirect_yarn_berry_vendored_entry code + remediation, and the generic detail naming the ACTUAL protocol. Currently failing (RED): core: yarn_berry_vendored_file_entry_refused_with_distinct_code_and_remediation, yarn_berry_unsupported_protocol_detail_names_actual_protocol cli: scan_agent_over_live_hosted_wiring_surfaces_run_level_warning, scan_agent_over_vendored_purl_surfaces_run_level_warning Co-Authored-By: Claude Fable 5 --- .../socket-patch-cli/tests/scan_invariants.rs | 310 ++++++++++++++++++ .../src/patch/redirect/mod.rs | 150 +++++++++ 2 files changed, 460 insertions(+) diff --git a/crates/socket-patch-cli/tests/scan_invariants.rs b/crates/socket-patch-cli/tests/scan_invariants.rs index 995862e9..260a9e65 100644 --- a/crates/socket-patch-cli/tests/scan_invariants.rs +++ b/crates/socket-patch-cli/tests/scan_invariants.rs @@ -1343,3 +1343,313 @@ async fn scan_detects_update_without_touching_existing_blobs() { let reqs = recorded(&mock).await; assert_single_batch_carries_purl(&reqs, purl); } + +// --------------------------------------------------------------------------- +// Cross-mode visibility warnings — agent-mode scan over another mode's state +// --------------------------------------------------------------------------- +// +// Additive run-level `warnings[]` (top-level `{code, detail}` entries on the +// scan `--json` envelope) — NEVER a status or exit-code change. Two blind +// spots they close: +// +// * `vendored_ownership_retained`: agent-mode apply partitions vendor-owned +// purls into `apply.patches[]` skip records (`skipped`/`vendored`), which +// a `--json` consumer only finds by digging into the per-patch array; the +// requested mode change silently did not happen at the envelope level. +// * `hosted_wiring_retained`: agent-mode scan over a live hosted redirect +// (lockfile still pinned to the patch server + redirect ledger records +// live) applies in place and reports success with zero hint that the +// hosted wiring was NOT unwound (no hosted revert exists for npm/yarn). + +const AGENT_WARN_UUID: &str = "33333333-3333-4333-8333-333333333333"; + +/// Mount the batch + by-package mocks for one purl/uuid pair. +async fn mount_patch_discovery(mock: &MockServer, purl: &str, encoded: &str, uuid: &str) { + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [{ + "purl": purl, + "patches": [{ + "uuid": uuid, + "purl": purl, + "tier": "free", + "cveIds": [], + "ghsaIds": [], + "severity": "high", + "title": "Prototype Pollution" + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(mock) + .await; + Mock::given(method("GET")) + .and(path(format!( + "/v0/orgs/{ORG_SLUG}/patches/by-package/{encoded}" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [{ + "uuid": uuid, + "purl": purl, + "publishedAt": "2024-01-01T00:00:00Z", + "description": "Fixes prototype pollution", + "license": "MIT", + "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(mock) + .await; +} + +fn find_warning<'a>(v: &'a serde_json::Value, code: &str) -> Option<&'a serde_json::Value> { + v["warnings"] + .as_array() + .and_then(|ws| ws.iter().find(|w| w["code"] == code)) +} + +#[tokio::test] +async fn scan_agent_over_vendored_purl_surfaces_run_level_warning() { + let mock = MockServer::start().await; + let purl = "pkg:npm/minimist@1.2.2"; + let encoded = "pkg%3Anpm%2Fminimist%401.2.2"; + mount_patch_discovery(&mock, purl, encoded, AGENT_WARN_UUID).await; + + let tmp = tempfile::tempdir().expect("tempdir"); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2"); + + // The vendor ledger owns the purl. + let vendor_dir = tmp.path().join(".socket/vendor"); + std::fs::create_dir_all(&vendor_dir).unwrap(); + std::fs::write( + vendor_dir.join("state.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "version": 1, + "entries": { purl: { + "ecosystem": "npm", + "basePurl": purl, + "uuid": AGENT_WARN_UUID, + "artifact": { + "path": format!(".socket/vendor/npm/{AGENT_WARN_UUID}/minimist-1.2.2.tgz"), + }, + "wiring": [] + }} + })) + .unwrap(), + ) + .unwrap(); + + let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &["--mode", "agent", "--yes"]); + assert_eq!( + code, 0, + "additive warning must NOT change the exit code; stdout={stdout}; stderr={stderr}" + ); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + assert_eq!( + v["status"], "success", + "additive warning must NOT change status; envelope={v}" + ); + + // The per-patch skip record is unchanged (contract-pinned elsewhere)… + let patches = v["apply"]["patches"].as_array().expect("apply.patches array"); + assert_eq!(patches.len(), 1, "envelope={v}"); + assert_eq!(patches[0]["errorCode"], "vendored", "envelope={v}"); + + // …and the NEW top-level run warning names the retained ownership. + let w = find_warning(&v, "vendored_ownership_retained").unwrap_or_else(|| { + panic!( + "agent-mode scan skipping vendor-owned purl(s) must surface a \ + top-level vendored_ownership_retained warning; envelope={v}" + ) + }); + let detail = w["detail"].as_str().expect("warning detail is a string"); + assert!( + detail.contains("pkg:npm/minimist@1.2.2"), + "must name the purl: {detail}" + ); + assert!( + detail.contains("socket-patch remove") && detail.contains("vendor --revert"), + "must name the real migration path: {detail}" + ); + // Mirrored to stderr (not silent). + assert!( + stderr.contains("vendored_ownership_retained"), + "warning must be mirrored to stderr when not silent: {stderr}" + ); +} + +/// Write a hosted redirect ledger + a yarn.lock the ledger claims to have +/// edited, whose resolved URL still pins the patch server (the live-wiring +/// proof `hosted_wiring_live` reads). +fn seed_live_hosted_wiring(root: &Path, purl: &str, uuid: &str, with_record: bool) { + let hosted_url = format!( + "https://patch.socket.dev/patch/npm/minimist/1.2.2/tok/{uuid}/minimist-1.2.2.tgz" + ); + std::fs::write( + root.join("yarn.lock"), + format!( + "# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.\n\ + # yarn lockfile v1\n\n\n\ + minimist@^1.2.2:\n version \"1.2.2\"\n \ + resolved \"{hosted_url}#aaaa\"\n integrity sha512-fake==\n" + ), + ) + .unwrap(); + let vendor_dir = root.join(".socket/vendor"); + std::fs::create_dir_all(&vendor_dir).unwrap(); + let mut state = serde_json::json!({ + "version": 1, + "mode": "hosted", + "edits": [{ + "path": "yarn.lock", + "kind": "redirect_yarn_entry", + "action": "rewritten", + "key": "minimist@1.2.2", + "original": "minimist@^1.2.2:\n version \"1.2.2\"\n resolved \"https://registry.yarnpkg.com/minimist/-/minimist-1.2.2.tgz#bbbb\"\n integrity sha512-orig==\n" + }], + }); + if with_record { + state["records"] = serde_json::json!({ purl: { + "uuid": uuid, + "exportedAt": "2024-01-01T00:00:00Z", + "files": {}, + "vulnerabilities": {}, + "description": "hosted patch", + "license": "MIT", + "tier": "free" + }}); + } + std::fs::write( + vendor_dir.join("redirect-state.json"), + serde_json::to_vec_pretty(&state).unwrap(), + ) + .unwrap(); +} + +#[tokio::test] +async fn scan_agent_over_live_hosted_wiring_surfaces_run_level_warning() { + let mock = MockServer::start().await; + let purl = "pkg:npm/minimist@1.2.2"; + let encoded = "pkg%3Anpm%2Fminimist%401.2.2"; + mount_patch_discovery(&mock, purl, encoded, AGENT_WARN_UUID).await; + + let tmp = tempfile::tempdir().expect("tempdir"); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2"); + seed_live_hosted_wiring(tmp.path(), purl, AGENT_WARN_UUID, /*with_record=*/ true); + + // --dry-run keeps the run cheap (no artifact download mocks needed); + // the warning is a STATE probe — the hosted wiring is live whether or + // not this particular run wrote anything. + let (code, stdout, stderr) = run_scan( + tmp.path(), + &mock.uri(), + &["--mode", "agent", "--dry-run", "--yes"], + ); + assert_eq!( + code, 0, + "additive warning must NOT change the exit code; stdout={stdout}; stderr={stderr}" + ); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + assert_eq!( + v["status"], "success", + "additive warning must NOT change status; envelope={v}" + ); + + let w = find_warning(&v, "hosted_wiring_retained").unwrap_or_else(|| { + panic!( + "agent-mode scan over live hosted wiring must surface a \ + top-level hosted_wiring_retained warning; envelope={v}" + ) + }); + let detail = w["detail"].as_str().expect("warning detail is a string"); + assert!( + detail.contains("pkg:npm/minimist@1.2.2"), + "must name the purl: {detail}" + ); + assert!( + detail.contains("scan --mode vendored"), + "must name the migration path: {detail}" + ); + assert!( + detail.contains("Do not delete"), + "must warn against hand-deleting the ledger (it holds the only \ + revert originals): {detail}" + ); + assert!( + stderr.contains("hosted_wiring_retained"), + "warning must be mirrored to stderr when not silent: {stderr}" + ); +} + +/// Coordination guard (lane B: hosted→vendored pre-revert): once another +/// flow retires the redirect ledger RECORDS for a purl, the agent-flow +/// warning must stay silent — it keys on records still live at scan time, +/// never on leftover `edits` (which are append-only revert data and +/// legitimately outlive the records). +#[tokio::test] +async fn scan_agent_hosted_warning_silent_once_ledger_records_are_gone() { + let mock = MockServer::start().await; + let purl = "pkg:npm/minimist@1.2.2"; + let encoded = "pkg%3Anpm%2Fminimist%401.2.2"; + mount_patch_discovery(&mock, purl, encoded, AGENT_WARN_UUID).await; + + let tmp = tempfile::tempdir().expect("tempdir"); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2"); + // Ledger with edits but NO records (the post-pre-revert shape) — and + // the lock text still carrying the uuid must not resurrect the warning. + seed_live_hosted_wiring(tmp.path(), purl, AGENT_WARN_UUID, /*with_record=*/ false); + + let (code, stdout, stderr) = run_scan( + tmp.path(), + &mock.uri(), + &["--mode", "agent", "--dry-run", "--yes"], + ); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + assert!( + find_warning(&v, "hosted_wiring_retained").is_none(), + "no ledger records ⇒ no hosted_wiring_retained warning; envelope={v}" + ); +} + +/// Registry-clean lock (hosted wiring NOT live) with a leftover record: +/// the live lock is the truth source — no warning. +#[tokio::test] +async fn scan_agent_hosted_warning_silent_when_lock_is_registry_clean() { + let mock = MockServer::start().await; + let purl = "pkg:npm/minimist@1.2.2"; + let encoded = "pkg%3Anpm%2Fminimist%401.2.2"; + mount_patch_discovery(&mock, purl, encoded, AGENT_WARN_UUID).await; + + let tmp = tempfile::tempdir().expect("tempdir"); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2"); + seed_live_hosted_wiring(tmp.path(), purl, AGENT_WARN_UUID, /*with_record=*/ true); + // Overwrite the lock with a registry-clean entry (no uuid, no patch host). + std::fs::write( + tmp.path().join("yarn.lock"), + "# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.\n\ + # yarn lockfile v1\n\n\n\ + minimist@^1.2.2:\n version \"1.2.2\"\n \ + resolved \"https://registry.yarnpkg.com/minimist/-/minimist-1.2.2.tgz#bbbb\"\n \ + integrity sha512-orig==\n", + ) + .unwrap(); + + let (code, stdout, stderr) = run_scan( + tmp.path(), + &mock.uri(), + &["--mode", "agent", "--dry-run", "--yes"], + ); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + assert!( + find_warning(&v, "hosted_wiring_retained").is_none(), + "registry-clean lock ⇒ no hosted_wiring_retained warning; envelope={v}" + ); +} diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 4ebb1150..753c7bb7 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -5158,6 +5158,156 @@ mod tests { .any(|w| w.code == "redirect_yarn_berry_ambiguous_entry")); } + /// A `file:` lock entry carrying the `.socket/vendor/` signature is + /// socket-patch's OWN vendored wiring (a `scan --mode vendored` project + /// being converted to hosted). Refusing it under the generic + /// `redirect_yarn_berry_unsupported_protocol` code misdiagnosed it — + /// the detail hardcoded "(workspace:/patch:/portal:/link:)" (`file:` + /// was not even listed) and named no way out. The refusal itself is + /// correct (fail-closed, byte-identical), but it must carry a DISTINCT + /// code and the real per-package remediation: retire the vendored + /// wiring first (`socket-patch remove `; `vendor --revert` + /// unwinds EVERY vendored package), then re-run `scan --mode hosted`. + /// That remedy holds whether or not a vendored→hosted pre-revert ever + /// lands for npm-family — today no berry counterpart of the cargo + /// takeover exists, so manual retirement is the only path. + #[test] + fn yarn_berry_vendored_file_entry_refused_with_distinct_code_and_remediation() { + let checksum = format!("10c0/{}", "7".repeat(128)); + let ovr = berry_override("minimist", "1.2.2", "http://p.test/minimist.tgz", &checksum); + let uuid = "80630680-4da6-45f9-bba8-b888e0ffd58c"; + let entry = format!( + "minimist@file:./.socket/vendor/npm/{uuid}/minimist-1.2.2.tgz::\ + locator=root%40workspace%3A." + ); + let mut files = BTreeMap::new(); + files.insert( + "yarn.lock".to_string(), + format!( + "# header\n\n__metadata:\n version: 8\n cacheKey: 10c0\n\n\ + \"{entry}\":\n version: 1.2.2\n resolution: \"{entry}\"\n \ + checksum: 10c0/{}\n languageName: node\n linkType: hard\n", + "3".repeat(128) + ), + ); + let mut r = RewriteResult::default(); + rewrite_yarn_berry(&files, std::slice::from_ref(&ovr), &mut r); + assert!( + r.files.is_empty() && r.edits.is_empty(), + "vendored entry must stay byte-identical: {:?}", + r.files + ); + let w = r + .warnings + .iter() + .find(|w| w.code == "redirect_yarn_berry_vendored_entry") + .unwrap_or_else(|| { + panic!( + "a vendored file: entry must get the distinct vendored-entry \ + code, not the generic protocol refusal: {:?}", + r.warnings + ) + }); + // Names the refused entry and the real remediation sequence. + assert!(w.detail.contains(&entry), "must name the entry: {}", w.detail); + assert!( + w.detail.contains("socket-patch remove"), + "must name the per-package remediation: {}", + w.detail + ); + assert!( + w.detail.contains("vendor --revert"), + "must name (and scope) the mass-revert alternative: {}", + w.detail + ); + assert!( + w.detail.contains("scan --mode hosted"), + "must name the re-run step: {}", + w.detail + ); + // The old misdiagnosis must be gone: no four-protocol list that + // does not even include `file:`. + assert!( + !w.detail.contains("(workspace:/patch:/portal:/link:)"), + "must not misdiagnose the vendored entry with the generic \ + protocol list: {}", + w.detail + ); + } + + /// The generic unsupported-protocol refusal must name the entry's + /// ACTUAL protocol (backticked), not a hardcoded four-item list that + /// omits, e.g., `file:` — an operator debugging the refusal needs the + /// real cause, and the old list actively misdirected for any protocol + /// outside it. + #[test] + fn yarn_berry_unsupported_protocol_detail_names_actual_protocol() { + let checksum = format!("10c0/{}", "7".repeat(128)); + let ovr = berry_override("left-pad", "1.3.0", "http://p.test/lp.tgz", &checksum); + let lock_with = |entry: &str| { + format!( + "# header\n\n__metadata:\n version: 8\n cacheKey: 10c0\n\n\ + \"{entry}\":\n version: 1.3.0\n resolution: \"{entry}\"\n \ + checksum: 10c0/{}\n languageName: node\n linkType: hard\n", + "3".repeat(128) + ) + }; + + // A portal: entry → generic code, detail names `portal:`. + let mut files = BTreeMap::new(); + files.insert( + "yarn.lock".to_string(), + lock_with("left-pad@portal:./vendor/left-pad::locator=root%40workspace%3A."), + ); + let mut r = RewriteResult::default(); + rewrite_yarn_berry(&files, std::slice::from_ref(&ovr), &mut r); + assert!(r.files.is_empty()); + let w = r + .warnings + .iter() + .find(|w| w.code == "redirect_yarn_berry_unsupported_protocol") + .expect("portal: entry keeps the generic refusal code"); + assert!( + w.detail.contains("`portal:`"), + "detail must name the actual protocol: {}", + w.detail + ); + + // A user's own file: entry OUTSIDE `.socket/vendor/` → still the + // generic code (not vendored-entry), detail names `file:`. + let mut files = BTreeMap::new(); + files.insert( + "yarn.lock".to_string(), + lock_with("left-pad@file:./local/left-pad.tgz::locator=root%40workspace%3A."), + ); + let mut r = RewriteResult::default(); + rewrite_yarn_berry(&files, std::slice::from_ref(&ovr), &mut r); + assert!(r.files.is_empty()); + let w = r + .warnings + .iter() + .find(|w| w.code == "redirect_yarn_berry_unsupported_protocol") + .unwrap_or_else(|| { + panic!( + "a non-vendored file: entry keeps the generic refusal \ + code: {:?}", + r.warnings + ) + }); + assert!( + w.detail.contains("`file:`"), + "detail must name the actual protocol: {}", + w.detail + ); + assert!( + !r.warnings + .iter() + .any(|w| w.code == "redirect_yarn_berry_vendored_entry"), + "vendored-entry code is reserved for `.socket/vendor/` wiring: {:?}", + r.warnings + ); + } + /// Two-entry classic lock: a decoy entry FIRST, the target second — the /// shape that exposed the CRLF wrong-entry rewrite. fn classic_lock_two_entries() -> String { From 779f206a55cd211473b05c2cbbd5864a8de733c7 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 18 Aug 2026 20:54:32 -0400 Subject: [PATCH 2/2] =?UTF-8?q?fix(scan):=20surface=20cross-mode=20state?= =?UTF-8?q?=20=E2=80=94=20hosted=20wiring=20retained,=20vendored=20ownersh?= =?UTF-8?q?ip,=20berry=20vendored-entry=20refusal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three additive-warning fixes for the yarn mode-conversion matrix's silence gaps (lane ADE). No exit-code or status changes anywhere — hosted refusals set the precedent (exit 0 + warning). A — hosted→agent silent no-op (scan/mod.rs agent branch): scan --mode agent over a live hosted redirect reported success/applied while yarn.lock stayed pinned to the patch server and .socket/vendor/redirect-state.json stayed live. The takeover machinery can never see this state: classify_overlap_takeover keys on purls in BOTH ledgers (hosted ∩ vendored), so hosted-ONLY wiring is structurally invisible (pinned by a new unit test). New hosted_wiring_retained_purls probes the already-loaded redirect ledger directly, reusing hosted_wiring_live per scanned purl, and emits a run-level hosted_wiring_retained warning (top-level warnings[] on the scan --json envelope + stderr; interactive path prints the same after apply). The probe keys on ledger RECORDS still live at scan time, so a sibling flow that pre-reverts hosted wiring (lane B) retires the warning with the records even while the append-only edits remain — pinned by tests. The detail offers the two real options (stay hosted / migrate via scan --mode vendored), never a hosted→agent unwind (none exists for npm/yarn) and never hand-deleting the ledger (the only revert data). D — vendored→agent refusal invisible at the envelope (scan/mod.rs:1564ff): agent-mode --json buried the vendor-owned skips deep in apply.patches[] (skipped/vendored) with no top-level signal. One run-level vendored_ownership_retained warning now names the purls + the real migration path (remove , or vendor --revert which unwinds EVERY vendored package, then re-run), mirrored to stderr when not --silent. Per-patch records, exit code, and status are untouched. E — berry rewriter misdiagnosis (core redirect/mod.rs:2039): the unsupported-protocol branch refused our OWN vendored file:./.socket/vendor/ entries with a detail hardcoding '(workspace:/patch:/portal:/link:)' — file: not even listed — and no remedy. A file: range carrying the .socket/vendor/ signature now gets the distinct redirect_yarn_berry_vendored_entry code whose detail names the retirement path (remove / scoped vendor --revert, then re-run scan --mode hosted — correct both before and after lane B, which covers the opposite direction); other protocols keep the generic code with the entry's ACTUAL protocol named. Refusal stays fail-closed and byte-identical. CLI_CONTRACT.md documents all three (agent-flow run-level warnings section, hosted rewriter refusal codes, vendored skip row/bullet). Tests: RED-committed integration tests now green (scan_invariants ×4, core rewriter ×2) plus new unit tests beside the takeover suite (structural-blindness pin, per-case silence guards, detail wording). Co-Authored-By: Claude Fable 5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 11 +- .../socket-patch-cli/src/commands/scan/mod.rs | 393 ++++++++++++++++++ .../socket-patch-cli/tests/scan_invariants.rs | 30 +- .../src/patch/redirect/mod.rs | 69 ++- 4 files changed, 488 insertions(+), 15 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 30e0e1dd..67e48701 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -86,6 +86,8 @@ Beyond the globals above, each subcommand defines a small set of local arguments `scan --apply` opts JSON callers into the full discover → select → apply pipeline. Without it, `scan --json` stays read-only (discovery + `updates` array only). No effect outside `--json` mode — the non-JSON path always prompts the user interactively. +**Agent-flow run-level warnings (additive).** An agent-mode apply (`--mode agent` / `--apply` / `--sync`, `--json`) may add a top-level `warnings[]` array of `{code, detail}` entries to the scan envelope (absent when none fired; each is also mirrored to stderr unless `--silent`). They surface cross-mode state the apply cannot change — never a status or exit-code change (hosted refusals set the precedent: exit 0 + warning). Codes (stable; new codes are additive/MINOR): `vendored_ownership_retained` — vendor-owned package(s) were skipped before download (the per-patch `skipped`/`vendored` records in `apply.patches[]` are unchanged); the detail names the purls and the migration path (`remove `, or `vendor --revert` which unwinds every vendored package, then re-run). `hosted_wiring_retained` — the hosted redirect ledger records scanned package(s) whose hosted lockfile wiring the live lock still proves (the agent run does not unwind hosted wiring; no npm/yarn hosted revert exists); the detail names the purls and the options (stay `--mode hosted`, or migrate via `scan --mode vendored`) and never advises hand-deleting the ledger. The warning keys on ledger *records* still live at scan time — a flow that pre-reverted the redirect (retiring the records) retires the warning with them, even while the append-only `edits` (revert originals) remain. The interactive path prints the same `hosted_wiring_retained` text to stderr after an apply; the vendored counterpart is already covered by its per-package `[skip] … (vendored …)` lines. + `scan --prune` opts into garbage collection. When set, `scan` removes manifest entries for packages no longer present in the crawl, then deletes orphan blob, diff, and package-archive files from `.socket/`. Off by default (v3.0) so a temporary uninstall doesn't silently destroy manifest state. Only entries whose ecosystem this run actually crawled are eligible: a `pkg:/` with no crawler in this build (a newer CLI's ecosystem in the committed manifest) and the runtime-gated maven/nuget crawlers with their gate off are exempt — the crawl never looked for them, so their absence is not evidence of removal (same fail-safe as the `--ecosystems` filter, which narrows the query but never the prune's installed set). The pass also reconciles vendored state (runs FIRST, under the apply lock — lock contention skips it without failing the scan): vendored entries whose patch is gone from the manifest are reverted, vendored entries whose dependency is no longer in the lockfile graph are reverted AND their manifest entries dropped (detached entries are exempt from both — they are manifest- and lockfile-invisible by design; a missing or undeterminable lockfile keeps the entry, fail-safe), and orphan `.socket/vendor//` dirs with no ledger entry are swept. The JSON `gc` sub-object gains `revertedVendoredEntries` + `removedVendorOrphanDirs` (wet) / `revertableVendoredEntries` + `vendorOrphanDirs` (preview). `scan` queries the patch API in `--batch-size` chunks. Authenticated runs POST `/v0/orgs/{slug}/patches/batch`; token-less runs POST `{proxy}/patch/batch` on the public proxy and degrade to per-package `GET /patch/by-package/:purl` requests in two cases: the deployed proxy predates the batch endpoint (legacy proxies answer the POST with their `400 "Unsupported endpoint"` catch-all), or the all-or-nothing batch validation rejects the chunk (e.g. a crawled PURL type the server doesn't recognize, such as `pkg:jsr/…` — the per-package path tolerates those individually, preserving the pre-batch scan semantics). Rate limits and over-capacity 503s surface instead of silently degrading. @@ -100,7 +102,7 @@ Beyond the globals above, each subcommand defines a small set of local arguments `scan --vendor --detached` performs the same vendoring **without ever writing `.socket/manifest.json`**: records are fetched into memory (`download.detached: true`), the artifacts are built + wired, and the ledger entry carries `detached: true` plus an embedded copy of the patch record (`record`) as the verification source. Detached patches are invisible to apply/rollback/repair (nothing is in the manifest), exempt from `vendor`'s manifest reconcile, and exit via `remove ` (which reverts them) or `vendor --revert`. Idempotent re-runs reuse the embedded record and skip the patch-view fetch entirely. -`scan --mode hosted` (== `--redirect`) swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL, or — for golang — the `patch.socket.dev/gopatch/` module path) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither recorded nor attested. Re-runs over already-rewritten output record zero new edits. JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). +`scan --mode hosted` (== `--redirect`) swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL, or — for golang — the `patch.socket.dev/gopatch/` module path) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither recorded nor attested. Re-runs over already-rewritten output record zero new edits. JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate and `bun.lock`), `requirements.txt` / `uv.lock`, `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (`yarn.lock` entry only — `resolution: ::__archiveUrl=` + `yarnBerry10c0` checksum; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` v1 — a binary `bun.lockb` with no text lock is auto-migrated to text via `bun install --save-text-lockfile --frozen-lockfile --lockfile-only` before the read, recorded as a `removed` FileEdit; `redirect_bun_lockb_would_migrate` on `--dry-run`, `redirect_bun_lockb_unsupported` when the migration is unavailable). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). @@ -572,7 +574,10 @@ worse, lets a warm cache silently serve unpatched bytes): * **scan skips vendored purls before download** (plain `--apply`/`--sync`): the manifest is never moved past the vendored uuid (that would break VEX verification with `vendor_uuid_mismatch` until a vendor run). The skip rides `apply.patches[]` as `skipped`/`vendored`; a newer available - patch still surfaces in `updates[]` — the signal to run `scan --vendor`. `scan --prune` exempts + patch still surfaces in `updates[]` — the signal to run `scan --vendor`. In `--json` mode the + run additionally carries one top-level `vendored_ownership_retained` warning naming the skipped + purls and the migration path (see "Agent-flow run-level warnings"), so consumers need not dig + into `apply.patches[]` to learn the mode did not change; exit code and status are unaffected. `scan --prune` exempts vendored purls from the crawl-based manifest prune (an absent installed copy is their NORMAL state) but reconciles vendored state via the lockfile instead — see the `--prune` section. An explicit `get` is allowed to move the manifest past the vendored uuid and warns @@ -846,7 +851,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `download_failed` | `failed` | repair/get: network or 404 on patch fetch. | | `cleanup_failed` | `skipped` (warning) | repair: an orphan-sweep pass (blobs, diff or package archives) failed mid-way (e.g. permission error). The run continues and exits 0; human mode carries the warning on stderr (not muted by `--silent`). | | `rollback_failed` | `failed` | remove/rollback: file restore could not complete. | -| `vendored` | `skipped` | apply (every ecosystem) + scan `--apply`: the package is managed by `socket-patch vendor`; the command yields ownership (scan also skips the download). Rollback surfaces the same skip via its `vendored: []` array. | +| `vendored` | `skipped` | apply (every ecosystem) + scan `--apply`: the package is managed by `socket-patch vendor`; the command yields ownership (scan also skips the download). Rollback surfaces the same skip via its `vendored: []` array. Scan `--apply --json` additionally surfaces one run-level `vendored_ownership_retained` warning naming the skipped purls (additive; exit/status unchanged). | | `vendor_reverted` | `removed` | remove: vendoring reverted (lock fragments restored, artifact + ledger entry gone) as part of removing the patch. | | `vendor_revert_failed` | top-level error | remove: the vendor revert failed; the manifest was NOT modified. | | `vendor_state_retained` | `skipped` | remove `--skip-rollback`: vendor wiring + artifact deliberately left in place (the next `vendor` run reconciles the dropped entry). Also the top-level error code when `--skip-rollback` targets a detached-only patch. | diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index d112ced1..1a628fbc 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -957,6 +957,164 @@ pub(super) async fn note_vendor_supersedes_redirect( }); } +// --------------------------------------------------------------------------- +// Agent-flow cross-mode visibility (hosted / vendored state left in place) +// --------------------------------------------------------------------------- +// +// The takeover machinery above covers hosted ⇄ vendored — the two modes that +// COMPETE for lockfile wiring. The agent flow competes with neither (it +// patches installed trees in place), so running `scan --mode agent` over +// another mode's live state is not a takeover: nothing goes stale, nothing +// is mutated. But it IS a mode conversion that silently did not complete, +// and the envelope said nothing: +// +// * over live HOSTED wiring, the agent apply succeeds against the already- +// patched bytes while the lockfile keeps resolving to the hosted patch +// server and the redirect ledger stays live — and no npm/yarn hosted +// revert exists, so the "conversion" can never complete without another +// mode run; +// * over VENDORED ownership, the apply partitions the vendor-owned purls +// into `apply.patches[]` skip records (`skipped`/`vendored`) that a +// `--json` consumer only finds by digging into the per-patch array. +// +// Both get one additive run-level warning (top-level `warnings[]` on the +// scan `--json` envelope + stderr when not silent). NEVER a status or +// exit-code change — hosted refusals set that precedent (exit 0 + warning). + +/// Warning code: agent-mode scan ran over package(s) whose hosted redirect +/// wiring is still LIVE (ledger record present AND the lock provably still +/// routes the purl to the hosted artifact). +pub(super) const HOSTED_WIRING_RETAINED: &str = "hosted_wiring_retained"; + +/// Warning code: agent-mode apply yielded ownership of vendor-owned +/// package(s) (the per-patch `skipped`/`vendored` records), so those +/// package(s) did NOT convert to agent mode. +pub(super) const VENDORED_OWNERSHIP_RETAINED: &str = "vendored_ownership_retained"; + +/// The scanned purls whose HOSTED redirect wiring is still live: the +/// redirect ledger records the purl AND [`hosted_wiring_live`] proves the +/// current lockfile still routes it to the hosted artifact. +/// +/// Deliberately NOT routed through [`classify_overlap_takeover`]: that +/// classifier keys on purls present in BOTH ledgers (hosted ∩ vendored), +/// so hosted-only wiring — the exact hosted→agent conversion state — can +/// structurally never trigger it (pinned by +/// `hosted_only_wiring_is_invisible_to_the_overlap_classifier`). +/// +/// Silent-by-construction cases (each pinned by a test): +/// * ledger absent/malformed or `records` empty — a hosted→vendored +/// pre-revert that retired the records must retire this warning with +/// them, even while the append-only `edits` (revert originals) remain; +/// * purl not scanned this run — the warning only ever names packages the +/// scan actually covered; +/// * the live lock does not prove hosted wiring (registry-clean lock, an +/// ecosystem whose lock we cannot read) — never guess from ledger +/// presence alone. +pub(super) async fn hosted_wiring_retained_purls( + cwd: &Path, + redirect_state: Option<&socket_patch_core::patch::redirect::RedirectState>, + scanned_purls: &HashSet, +) -> Vec { + let Some(redirect) = redirect_state else { + return Vec::new(); + }; + if redirect.records.is_empty() { + return Vec::new(); + } + let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned(); + let scanned: std::collections::BTreeSet = + scanned_purls.iter().map(|p| canon(p)).collect(); + let mut redirect_files: Vec<&str> = redirect.edits.iter().map(|e| e.path.as_str()).collect(); + redirect_files.sort(); + redirect_files.dedup(); + let inventory = socket_patch_core::vendor::lock_inventory::inventory_project(cwd).await; + let mut out = Vec::new(); + for (key, record) in &redirect.records { + let purl = canon(key); + if !scanned.contains(&purl) { + continue; + } + if hosted_wiring_live( + cwd, + &purl, + Some(record.uuid.as_str()), + &redirect_files, + &inventory, + ) + .await + { + out.push(purl); + } + } + out.sort(); + out.dedup(); + out +} + +/// Detail for [`HOSTED_WIRING_RETAINED`]. Names the package(s) and the two +/// real options — stay hosted, or migrate via the vendored flow (which +/// reconciles the superseded ledger entries per package). It must never +/// advise hand-deleting the redirect ledger (the only store of the +/// pre-redirect originals plus the records VEX reads) and never promise a +/// hosted→agent unwind that does not exist for npm/yarn. +pub(super) fn hosted_wiring_retained_detail(retained: &[String]) -> String { + let list = retained.join(", "); + format!( + "agent-mode scan left the hosted redirect wiring live for: {list}. \ + The lockfile still resolves these package(s) to the hosted patch \ + server and `.socket/vendor/redirect-state.json` still records the \ + redirect — an agent run patches installed files in place but does \ + NOT unwind hosted lockfile wiring (no hosted revert exists for \ + this ecosystem yet), so installs keep fetching these package(s) \ + from the patch server. Either keep the project in hosted mode \ + (`scan --mode hosted`), or migrate to committed artifacts with \ + `scan --mode vendored`, which takes these package(s) over in the \ + lockfile and reconciles the superseded redirect ledger entries. \ + Do not delete `.socket/vendor/redirect-state.json` by hand: it \ + holds the recorded pre-redirect lockfile originals (the only \ + revert data) and the redirect records VEX reads." + ) +} + +/// Detail for [`VENDORED_OWNERSHIP_RETAINED`]. Names the vendor-owned +/// package(s) the agent apply skipped and the real migration path — +/// per-package `remove ` first (with `vendor --revert` named but +/// scoped: it unwinds EVERY vendored package), then re-run. +pub(super) fn vendored_ownership_retained_detail(purls: &[String]) -> String { + let list = purls + .iter() + .map(|p| normalize_purl(p).into_owned()) + .collect::>() + .join(", "); + format!( + "agent-mode apply did not take over vendor-owned package(s): {list}. \ + These package(s) are managed by `socket-patch vendor` (committed \ + `.socket/vendor/` artifacts own their lockfile wiring), so they \ + were skipped before download — recorded in `apply.patches[]` as \ + `skipped`/`vendored` — and stay in vendored mode. To keep them \ + vendored, no action is needed. To migrate a package to agent \ + mode, first retire its vendored wiring: run `socket-patch remove \ + ` for that package (or `socket-patch vendor --revert`, \ + which unwinds EVERY vendored package), then re-run `scan --mode \ + agent`." + ) +} + +/// Append one `{code, detail}` entry to the scan `--json` result's +/// top-level `warnings` array (created on first use — the key is additive +/// and absent when no run-level warning fired), mirroring the +/// [`crate::json_envelope::RunWarning`] wire shape. +fn push_scan_json_warning(result: &mut serde_json::Value, code: &str, detail: &str) { + let warnings = result + .as_object_mut() + .expect("scan JSON result is an object") + .entry("warnings") + .or_insert_with(|| serde_json::json!([])); + if let Some(arr) = warnings.as_array_mut() { + arr.push(serde_json::json!({ "code": code, "detail": detail })); + } +} + pub async fn run(mut args: ScanArgs) -> i32 { apply_env_toggles(&args.common); @@ -1566,6 +1724,14 @@ pub async fn run(mut args: ScanArgs) -> i32 { |p| vendored_purls.contains(p) || vendored_purls.contains(strip_purl_qualifiers(p)), "vendored", ); + // Captured from the vendored partition ONLY (before the + // not-installed skips merge in below — those are a different, + // already-calm class): feeds the run-level + // `vendored_ownership_retained` warning emitted after apply. + let vendored_skip_purls: Vec = vendored_records + .iter() + .filter_map(|r| r["purl"].as_str().map(str::to_string)) + .collect(); // Lockfile-only purls leave the apply selection here (calm // skip records, never an error); the union rides the same // bookkeeping as the vendored skips. @@ -1652,6 +1818,36 @@ pub async fn run(mut args: ScanArgs) -> i32 { result["status"] = serde_json::json!("partial_failure"); } } + + // Cross-mode visibility (additive run-level warnings; never a + // status or exit-code change — see the constants' docs): + // + // * vendor-owned purls were partitioned out above — surface + // them at the envelope level instead of only deep inside + // `apply.patches[]`; + // * hosted redirect wiring the live lock still proves — the + // agent run cannot unwind it, so silence here reads as a + // completed conversion that never happened. + if !vendored_skip_purls.is_empty() { + let detail = vendored_ownership_retained_detail(&vendored_skip_purls); + if !args.common.silent { + eprintln!("Warning ({VENDORED_OWNERSHIP_RETAINED}): {detail}"); + } + push_scan_json_warning(&mut result, VENDORED_OWNERSHIP_RETAINED, &detail); + } + let hosted_retained = hosted_wiring_retained_purls( + &args.common.cwd, + redirect_state.as_ref(), + &scanned_purls, + ) + .await; + if !hosted_retained.is_empty() { + let detail = hosted_wiring_retained_detail(&hosted_retained); + if !args.common.silent { + eprintln!("Warning ({HOSTED_WIRING_RETAINED}): {detail}"); + } + push_scan_json_warning(&mut result, HOSTED_WIRING_RETAINED, &detail); + } // --- Vendor path (if requested; conflicts with --apply/--sync) --- } else if vendor { // Extracted into its own boxed fn — and it must STAY extracted: @@ -2158,6 +2354,24 @@ pub async fn run(mut args: ScanArgs) -> i32 { code }; + // Cross-mode visibility, mirroring the JSON apply path: after an + // in-place apply, warn when the hosted redirect wiring is still live + // for scanned package(s) — the apply cannot unwind it, and silence + // reads as a completed hosted→agent conversion that never happened. + // (The vendored-ownership counterpart is already printed per package + // by the `[skip] … (vendored …)` lines above.) + if !vendor && !args.common.silent { + let hosted_retained = + hosted_wiring_retained_purls(&args.common.cwd, redirect_state.as_ref(), &scanned_purls) + .await; + if !hosted_retained.is_empty() { + eprintln!( + "Warning ({HOSTED_WIRING_RETAINED}): {}", + hosted_wiring_retained_detail(&hosted_retained) + ); + } + } + // Post-apply GC: only runs when the user opted in via `--prune` or // `--sync`. Default `scan --yes` no longer touches the manifest // beyond what `--apply` added — users wanting to clean up should @@ -2397,6 +2611,185 @@ mod tests { assert_ne!(VENDOR_SUPERSEDES_REDIRECT, REDIRECT_SUPERSEDES_VENDORED); } + // ---- agent-flow hosted-wiring retention (hosted → agent conversion) ---- + // The overlap classifier keys on purls present in BOTH ledgers, so + // hosted-ONLY wiring (the exact hosted→agent conversion state: redirect + // ledger live, no vendor state.json) can structurally never trigger it. + // The agent flow probes the redirect ledger + live lock directly and + // emits `hosted_wiring_retained`. These pin the trigger, every + // non-trigger, and the remediation wording. + + /// Redirect ledger with one record per PURL AND a recorded `yarn.lock` + /// edit — the shape a real hosted run leaves behind (the edit is what + /// lets `hosted_wiring_live`'s text proof scan the lock). + async fn write_redirect_ledger_with_edit(root: &Path, purls: &[&str]) { + use socket_patch_core::patch::redirect::{FileEdit, RedirectState}; + let mut state = RedirectState::new(); + for purl in purls { + state.records.insert((*purl).to_string(), takeover_record()); + } + state.edits.push(FileEdit { + path: "yarn.lock".to_string(), + kind: "redirect_yarn_entry".to_string(), + action: "rewritten".to_string(), + key: Some("minimist@1.2.2".to_string()), + original: Some(serde_json::Value::String("registry original".to_string())), + new: None, + }); + let dir = root.join(".socket/vendor"); + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write( + dir.join("redirect-state.json"), + serde_json::to_string_pretty(&state).unwrap(), + ) + .await + .unwrap(); + } + + /// yarn classic lock whose resolved URL is the hosted artifact (carries + /// the record uuid) — the live-hosted-wiring proof. + async fn write_hosted_yarn_lock(root: &Path, uuid: &str) { + tokio::fs::write( + root.join("yarn.lock"), + format!( + "# yarn lockfile v1\n\n\nminimist@^1.2.2:\n version \"1.2.2\"\n \ + resolved \"https://patch.socket.dev/patch/npm/minimist/1.2.2/tok/{uuid}/minimist-1.2.2.tgz#aaaa\"\n \ + integrity sha512-fake==\n" + ), + ) + .await + .unwrap(); + } + + async fn load_ledger(root: &Path) -> Option { + socket_patch_core::patch::redirect::load_redirect_state(root) + .await + .unwrap() + } + + #[tokio::test] + async fn hosted_only_wiring_fires_agent_probe_not_the_overlap_classifier() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let purl = "pkg:npm/minimist@1.2.2"; + write_redirect_ledger_with_edit(root, &[purl]).await; + write_hosted_yarn_lock(root, TAKEOVER_UUID).await; + + // Hosted-only wiring (no vendor state.json) is structurally + // invisible to the hosted⇄vendored overlap classifier… + assert!(overlapping_ledger_purls(root).await.is_empty()); + assert_eq!( + classify_overlap_takeover(root).await, + OverlapTakeover::default() + ); + + // …but the agent flow's direct probe sees it for scanned purls. + let scanned: HashSet = [purl.to_string()].into_iter().collect(); + let ledger = load_ledger(root).await; + let retained = hosted_wiring_retained_purls(root, ledger.as_ref(), &scanned).await; + assert_eq!(retained, vec![purl.to_string()]); + } + + #[tokio::test] + async fn hosted_retained_probe_is_silent_without_live_records_or_wiring() { + let purl = "pkg:npm/minimist@1.2.2"; + let scanned: HashSet = [purl.to_string()].into_iter().collect(); + + // (a) Records retired — the lane-B (hosted→vendored pre-revert) + // world: the pre-revert drops the ledger RECORDS while the + // append-only `edits` (revert originals) legitimately remain. The + // warning keys on records still live at scan time, so it must stay + // silent even with the uuid still present in the lock text. + let tmp = tempfile::tempdir().unwrap(); + write_redirect_ledger_with_edit(tmp.path(), &[]).await; + write_hosted_yarn_lock(tmp.path(), TAKEOVER_UUID).await; + let ledger = load_ledger(tmp.path()).await; + assert!( + hosted_wiring_retained_purls(tmp.path(), ledger.as_ref(), &scanned) + .await + .is_empty(), + "records gone ⇒ silent (pre-reverted wiring must not re-warn)" + ); + + // (b) Registry-clean lock with a live record: the live lock is the + // truth source — never guess from ledger presence alone. + let tmp = tempfile::tempdir().unwrap(); + write_redirect_ledger_with_edit(tmp.path(), &[purl]).await; + tokio::fs::write( + tmp.path().join("yarn.lock"), + "# yarn lockfile v1\n\n\nminimist@^1.2.2:\n version \"1.2.2\"\n \ + resolved \"https://registry.yarnpkg.com/minimist/-/minimist-1.2.2.tgz#bbbb\"\n \ + integrity sha512-orig==\n", + ) + .await + .unwrap(); + let ledger = load_ledger(tmp.path()).await; + assert!( + hosted_wiring_retained_purls(tmp.path(), ledger.as_ref(), &scanned) + .await + .is_empty(), + "registry-clean lock ⇒ silent" + ); + + // (c) The purl was not scanned this run. + let tmp = tempfile::tempdir().unwrap(); + write_redirect_ledger_with_edit(tmp.path(), &[purl]).await; + write_hosted_yarn_lock(tmp.path(), TAKEOVER_UUID).await; + let other: HashSet = ["pkg:npm/lodash@4.17.21".to_string()].into_iter().collect(); + let ledger = load_ledger(tmp.path()).await; + assert!( + hosted_wiring_retained_purls(tmp.path(), ledger.as_ref(), &other) + .await + .is_empty(), + "unscanned purl ⇒ silent" + ); + + // (d) No ledger at all. + let tmp = tempfile::tempdir().unwrap(); + write_hosted_yarn_lock(tmp.path(), TAKEOVER_UUID).await; + assert!( + hosted_wiring_retained_purls(tmp.path(), None, &scanned) + .await + .is_empty(), + "no ledger ⇒ silent" + ); + } + + #[test] + fn agent_retention_details_name_packages_and_safe_remediation() { + let purls = vec!["pkg:npm/minimist@1.2.2".to_string()]; + + // hosted_wiring_retained: names the purl and both real options + // (stay hosted / migrate via vendored), never a hosted→agent + // unwind (none exists) and never hand-deleting the ledger (the + // only store of the pre-redirect revert originals). + let hosted = hosted_wiring_retained_detail(&purls); + assert!(hosted.contains("pkg:npm/minimist@1.2.2")); + assert!(hosted.contains("scan --mode hosted")); + assert!(hosted.contains("scan --mode vendored")); + assert!( + hosted.contains("Do not delete"), + "must warn against hand-deleting the ledger: {hosted}" + ); + + // vendored_ownership_retained: names the purl and the per-package + // migration path, with the mass-revert alternative scoped. + let vendored = vendored_ownership_retained_detail(&purls); + assert!(vendored.contains("pkg:npm/minimist@1.2.2")); + assert!(vendored.contains("socket-patch remove")); + assert!(vendored.contains("vendor --revert")); + assert!( + vendored.contains("EVERY vendored package"), + "the mass-revert blast radius must be called out: {vendored}" + ); + assert!(vendored.contains("scan --mode agent")); + + // Distinct routing tags, also distinct from the takeover family. + assert_ne!(HOSTED_WIRING_RETAINED, VENDORED_OWNERSHIP_RETAINED); + assert_ne!(HOSTED_WIRING_RETAINED, REDIRECT_SUPERSEDES_VENDORED); + assert_ne!(VENDORED_OWNERSHIP_RETAINED, VENDOR_SUPERSEDES_REDIRECT); + } + // ---- cargo takeover direction (lock-shape probe) ------------------------ // The scan inventory records `resolved: None` for every cargo entry, so // the generic patch.socket.dev check can never prove hosted for cargo — diff --git a/crates/socket-patch-cli/tests/scan_invariants.rs b/crates/socket-patch-cli/tests/scan_invariants.rs index 260a9e65..0989389a 100644 --- a/crates/socket-patch-cli/tests/scan_invariants.rs +++ b/crates/socket-patch-cli/tests/scan_invariants.rs @@ -1454,7 +1454,9 @@ async fn scan_agent_over_vendored_purl_surfaces_run_level_warning() { ); // The per-patch skip record is unchanged (contract-pinned elsewhere)… - let patches = v["apply"]["patches"].as_array().expect("apply.patches array"); + let patches = v["apply"]["patches"] + .as_array() + .expect("apply.patches array"); assert_eq!(patches.len(), 1, "envelope={v}"); assert_eq!(patches[0]["errorCode"], "vendored", "envelope={v}"); @@ -1485,9 +1487,8 @@ async fn scan_agent_over_vendored_purl_surfaces_run_level_warning() { /// edited, whose resolved URL still pins the patch server (the live-wiring /// proof `hosted_wiring_live` reads). fn seed_live_hosted_wiring(root: &Path, purl: &str, uuid: &str, with_record: bool) { - let hosted_url = format!( - "https://patch.socket.dev/patch/npm/minimist/1.2.2/tok/{uuid}/minimist-1.2.2.tgz" - ); + let hosted_url = + format!("https://patch.socket.dev/patch/npm/minimist/1.2.2/tok/{uuid}/minimist-1.2.2.tgz"); std::fs::write( root.join("yarn.lock"), format!( @@ -1539,7 +1540,12 @@ async fn scan_agent_over_live_hosted_wiring_surfaces_run_level_warning() { let tmp = tempfile::tempdir().expect("tempdir"); write_root_package_json(tmp.path()); write_npm_package(tmp.path(), "minimist", "1.2.2"); - seed_live_hosted_wiring(tmp.path(), purl, AGENT_WARN_UUID, /*with_record=*/ true); + seed_live_hosted_wiring( + tmp.path(), + purl, + AGENT_WARN_UUID, + /*with_record=*/ true, + ); // --dry-run keeps the run cheap (no artifact download mocks needed); // the warning is a STATE probe — the hosted wiring is live whether or @@ -1602,7 +1608,12 @@ async fn scan_agent_hosted_warning_silent_once_ledger_records_are_gone() { write_npm_package(tmp.path(), "minimist", "1.2.2"); // Ledger with edits but NO records (the post-pre-revert shape) — and // the lock text still carrying the uuid must not resurrect the warning. - seed_live_hosted_wiring(tmp.path(), purl, AGENT_WARN_UUID, /*with_record=*/ false); + seed_live_hosted_wiring( + tmp.path(), + purl, + AGENT_WARN_UUID, + /*with_record=*/ false, + ); let (code, stdout, stderr) = run_scan( tmp.path(), @@ -1629,7 +1640,12 @@ async fn scan_agent_hosted_warning_silent_when_lock_is_registry_clean() { let tmp = tempfile::tempdir().expect("tempdir"); write_root_package_json(tmp.path()); write_npm_package(tmp.path(), "minimist", "1.2.2"); - seed_live_hosted_wiring(tmp.path(), purl, AGENT_WARN_UUID, /*with_record=*/ true); + seed_live_hosted_wiring( + tmp.path(), + purl, + AGENT_WARN_UUID, + /*with_record=*/ true, + ); // Overwrite the lock with a registry-clean entry (no uuid, no patch host). std::fs::write( tmp.path().join("yarn.lock"), diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 753c7bb7..7f2e6067 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -2041,13 +2041,68 @@ fn rewrite_yarn_berry( .1 .starts_with("npm:") }) { + let ranges: Vec<&str> = parsed + .iter() + .map(|p| { + p.expect("every pattern parsed — None-bearing keys are skipped above") + .1 + }) + .collect(); + // A `file:` range into `.socket/vendor/` is socket-patch's + // OWN vendored wiring (`scan --mode vendored`), not some + // third-party protocol: the refusal stays fail-closed + // (byte-identical — the vendored artifact is the live CVE + // protection), but it must say what the entry IS and name + // the real way out. `remove ` is the per-package + // retirement; `vendor --revert` works too but unwinds EVERY + // vendored package, so it is scoped, not recommended. The + // remedy holds whether or not a vendored→hosted pre-revert + // ever lands for npm-family — today no berry counterpart of + // the cargo takeover exists. + if ranges + .iter() + .any(|r| r.starts_with("file:") && r.contains(".socket/vendor/")) + { + result.warnings.push(RewriteWarning { + code: "redirect_yarn_berry_vendored_entry".into(), + detail: format!( + "lock entry `{raw_key}` is socket-patch's own vendored wiring \ + for {fname}@{} (a committed `.socket/vendor/` artifact); the \ + hosted redirect does not take over a vendor-owned package — \ + leaving it byte-identical. To move this package to hosted \ + mode, first retire its vendored wiring: run `socket-patch \ + remove ` for this package (or `socket-patch vendor \ + --revert`, which unwinds EVERY vendored package), then re-run \ + `scan --mode hosted`", + dep.version + ), + }); + continue; + } + // Name the entry's ACTUAL protocol(s) — a hardcoded example + // list misdirects for anything outside it (`file:`, `exec:`, + // …). `workspace:`/`patch:`/`portal:`/`link:` stay the + // canonical examples of why the gate exists. + let mut protocols: Vec = ranges + .iter() + .filter(|r| !r.starts_with("npm:")) + .map(|r| match r.split_once(':') { + Some((proto, _)) => format!("{proto}:"), + None => "(none)".to_string(), + }) + .collect(); + protocols.sort(); + protocols.dedup(); result.warnings.push(RewriteWarning { code: "redirect_yarn_berry_unsupported_protocol".into(), detail: format!( - "lock entry `{raw_key}` resolves {fname}@{} through a protocol \ - the hosted redirect cannot own (workspace:/patch:/portal:/link:); \ - leaving it byte-identical", - dep.version + "lock entry `{raw_key}` resolves {fname}@{} through the `{}` \ + protocol, which the hosted redirect cannot own (only npm: \ + registry entries are rewritten; e.g. workspace:, patch:, \ + portal:, link: blocks must survive untouched); leaving it \ + byte-identical", + dep.version, + protocols.join("`/`") ), }); continue; @@ -5209,7 +5264,11 @@ mod tests { ) }); // Names the refused entry and the real remediation sequence. - assert!(w.detail.contains(&entry), "must name the entry: {}", w.detail); + assert!( + w.detail.contains(&entry), + "must name the entry: {}", + w.detail + ); assert!( w.detail.contains("socket-patch remove"), "must name the per-package remediation: {}",