From 5d5fbb20115167bb7e3bdbea19e11699dbff547f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 01:52:06 +0000 Subject: [PATCH 1/3] Start hermetic slice for #823 Assisted-by: Claude Code:claude-opus-5-5 From d4d852a805ee7e865ee4224600cf3fa6519d1061 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 02:01:08 +0000 Subject: [PATCH 2/3] Spawn scan, policy and network tests hermetically e2e_scan, e2e_socket_yml_policy and apply/apply_network started socket-patch with a bare Command::new and their own SOCKET_* sweeps. apply_network only removed SOCKET_API_TOKEN in most tests, so a developer shell exporting SOCKET_DRY_RUN or SOCKET_ECOSYSTEMS failed 9 of its tests. All three sweeps let an ambient SOCKET_NO_CONFIG=0 through, so a real `socket login` could reach the child, and none forced the update check off. All three now spawn through hermetic::command. The policy suite also scrubs Bundler env, since its fixture includes a gem. The npm children in e2e_scan drop ambient SOCKET_* too. The files leave PENDING_RAW_SPAWNS, so the spawn_env_hygiene ratchet keeps them hermetic. Refs #823 Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/apply/apply_network.rs | 42 +++++++------------ crates/socket-patch-cli/tests/e2e_scan.rs | 26 ++++-------- .../tests/e2e_socket_yml_policy.rs | 18 ++++---- .../tests/spawn_env_hygiene.rs | 3 -- 4 files changed, 31 insertions(+), 58 deletions(-) diff --git a/crates/socket-patch-cli/tests/apply/apply_network.rs b/crates/socket-patch-cli/tests/apply/apply_network.rs index 3c5724157..845f21120 100644 --- a/crates/socket-patch-cli/tests/apply/apply_network.rs +++ b/crates/socket-patch-cli/tests/apply/apply_network.rs @@ -11,12 +11,11 @@ //! node_modules and leaves `.socket/` byte-identical. use std::path::Path; -use std::process::Command; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use crate::common::{binary, git_sha256}; +use crate::common::{binary, git_sha256, hermetic}; const ORG_SLUG: &str = "test-org"; @@ -79,7 +78,7 @@ fn run_apply(cwd: &Path, api_url: &str, extra: &[&str]) -> (i32, String, String) // --api-url/--api-token/--org flags). let mut argv: Vec<&str> = vec!["apply", "--json"]; argv.extend_from_slice(extra); - let out = Command::new(binary()) + let out = hermetic::command(&binary()) .args(&argv) .current_dir(cwd) .env("SOCKET_API_URL", api_url) @@ -323,10 +322,9 @@ async fn apply_dry_run_emits_verified_event_without_writing() { std::fs::write(blobs.join(&after_hash), after).unwrap(); // No mock needed — apply finds everything locally. - let out = Command::new(binary()) + let out = hermetic::command(&binary()) .args(["apply", "--json", "--dry-run", "--offline"]) .current_dir(tmp.path()) - .env_remove("SOCKET_API_TOKEN") .output() .expect("run socket-patch"); let code = out.status.code().unwrap_or(-1); @@ -403,10 +401,9 @@ async fn apply_with_force_overrides_hash_mismatch() { // Without --force apply should fail (hash mismatch). With --force it // should bypass the verification and write the patched content. - let out = Command::new(binary()) + let out = hermetic::command(&binary()) .args(["apply", "--json", "--offline", "--force"]) .current_dir(tmp.path()) - .env_remove("SOCKET_API_TOKEN") .output() .expect("run socket-patch"); let code = out.status.code().unwrap_or(-1); @@ -467,10 +464,9 @@ async fn apply_hash_mismatch_default_warns_and_applies_strict_fails() { // DEFAULT: the mismatch is overwritten with the full verified patched // content (the blob is hash-gated to afterHash) and surfaced as a warning event — exit 0. let tmp = fixture(); - let out = Command::new(binary()) + let out = hermetic::command(&binary()) .args(["apply", "--json", "--offline"]) .current_dir(tmp.path()) - .env_remove("SOCKET_API_TOKEN") .output() .expect("run socket-patch"); let stdout = String::from_utf8_lossy(&out.stdout).to_string(); @@ -500,10 +496,9 @@ async fn apply_hash_mismatch_default_warns_and_applies_strict_fails() { // The human run logs the warning to stderr. let tmp = fixture(); - let out = Command::new(binary()) + let out = hermetic::command(&binary()) .args(["apply", "--offline", "--yes"]) .current_dir(tmp.path()) - .env_remove("SOCKET_API_TOKEN") .output() .expect("run socket-patch"); let stderr = String::from_utf8_lossy(&out.stderr); @@ -515,10 +510,9 @@ async fn apply_hash_mismatch_default_warns_and_applies_strict_fails() { // --strict: fail closed — exit 1, failed event, file untouched. let tmp = fixture(); - let out = Command::new(binary()) + let out = hermetic::command(&binary()) .args(["apply", "--json", "--offline", "--strict"]) .current_dir(tmp.path()) - .env_remove("SOCKET_API_TOKEN") .output() .expect("run socket-patch"); let stdout = String::from_utf8_lossy(&out.stdout).to_string(); @@ -593,10 +587,9 @@ async fn apply_pypi_package_uses_python_crawler() { // crawler and patch it — exercising the pypi dispatch branch end to // end, not just "without panicking". `VIRTUAL_ENV` is cleared so an // ambient venv in CI can't redirect discovery away from our `.venv`. - let out = Command::new(binary()) + let out = hermetic::command(&binary()) .args(["apply", "--json", "--offline", "--ecosystems", "pypi"]) .current_dir(tmp.path()) - .env_remove("SOCKET_API_TOKEN") .env_remove("VIRTUAL_ENV") .output() .expect("run socket-patch"); @@ -656,10 +649,9 @@ async fn apply_uses_locally_cached_blob_without_fetching() { // No mock server. If apply tries to hit the network, the test will // fail (connection refused) — proving the local-blob fast path is // taken when sources are already on disk. - let out = Command::new(binary()) + let out = hermetic::command(&binary()) .args(["apply", "--json"]) .current_dir(tmp.path()) - .env_remove("SOCKET_API_TOKEN") .env( "SOCKET_API_URL", "http://127.0.0.1:1", // unreachable port — should never be contacted @@ -819,18 +811,14 @@ async fn offline_apply_with_token_makes_zero_network_requests() { std::fs::create_dir_all(&blobs).expect("create blobs"); std::fs::write(blobs.join(&after_hash), after).expect("stage blob"); - let mut cmd = Command::new(binary()); + let mut cmd = hermetic::command(&binary()); cmd.args(["apply", "--json", "--offline"]) .current_dir(tmp.path()); - // Scrub ambient SOCKET_* (an ambient SOCKET_ORG_SLUG would skip the - // auto-resolution and make this test vacuously green), then seed only - // what the scenario needs: token set, org absent, both URLs pinned to - // the mock so any traffic — API or proxy path — is captured. - for (k, _) in std::env::vars_os() { - if k.to_string_lossy().starts_with("SOCKET_") && k.to_string_lossy() != "SOCKET_NO_CONFIG" { - cmd.env_remove(&k); - } - } + // The builder scrubs ambient SOCKET_* (an ambient SOCKET_ORG_SLUG would + // skip the auto-resolution and make this test vacuously green) and + // keeps the config layer off; seed only what the scenario needs: token + // set, org absent, both URLs pinned to the mock so any traffic — API or + // proxy path — is captured. cmd.env("SOCKET_API_URL", mock.uri()) .env("SOCKET_PROXY_URL", mock.uri()) .env("SOCKET_API_TOKEN", "fake-token-for-test"); diff --git a/crates/socket-patch-cli/tests/e2e_scan.rs b/crates/socket-patch-cli/tests/e2e_scan.rs index 0487bccb0..4d9a79a1e 100644 --- a/crates/socket-patch-cli/tests/e2e_scan.rs +++ b/crates/socket-patch-cli/tests/e2e_scan.rs @@ -29,7 +29,7 @@ #[path = "common/mod.rs"] mod common; -use common::{binary, git_sha256}; +use common::{binary, git_sha256, hermetic}; use std::path::Path; use std::process::{Command, Output}; @@ -93,24 +93,13 @@ fn git_sha256_file(path: &Path) -> String { } fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { - let mut cmd = Command::new(binary()); + // The shared builder scrubs every ambient `SOCKET_*` (an ambient + // SOCKET_DRY_RUN would turn every `scan --mode agent` into a preview, + // SOCKET_GLOBAL would aim mutations at the host's real global + // node_modules) and forces the config layer and update check off; + // removing SOCKET_API_TOKEN also forces the public proxy (free-tier). + let mut cmd = hermetic::command(&binary()); cmd.args(args).current_dir(cwd); - // The binary binds a wide `SOCKET_*` env surface (SOCKET_CWD, - // SOCKET_DRY_RUN, SOCKET_GLOBAL, SOCKET_GLOBAL_PREFIX, SOCKET_PROXY_URL, - // SOCKET_MANIFEST_PATH, ...). An ambient value silently changes what - // these tests exercise — SOCKET_DRY_RUN=true turns every `scan --mode agent` - // into a no-op preview, and SOCKET_GLOBAL aims mutations at the host's - // *real* global node_modules. Scrub the whole prefix so only the flags - // each test passes are in effect; removing SOCKET_API_TOKEN also forces - // the public proxy (free-tier). Telemetry opt-outs are deliberately kept - // so an opted-out dev stays opted out. - for (key, _) in std::env::vars_os() { - let name = key.to_string_lossy(); - if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") && name != "SOCKET_NO_CONFIG" - { - cmd.env_remove(&key); - } - } let out: Output = cmd.output().expect("failed to execute socket-patch binary"); let code = out.status.code().unwrap_or(-1); let stdout = String::from_utf8_lossy(&out.stdout).to_string(); @@ -130,6 +119,7 @@ fn assert_run_ok(cwd: &Path, args: &[&str], context: &str) -> (String, String) { fn npm_run(cwd: &Path, args: &[&str]) { let mut cmd = Command::new("npm"); cmd.args(args).current_dir(cwd); + hermetic::scrub_socket_vars(&mut cmd); cache_env::isolate(&mut cmd); let out = cmd.output().expect("failed to run npm"); assert!( diff --git a/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs b/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs index 200c40de4..2f9460ece 100644 --- a/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs +++ b/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs @@ -3,6 +3,8 @@ //! scanned through the real binary in hosted, agent and vendored mode //! against a mock patch API that serves a small catalog. +#[path = "common/hermetic.rs"] +mod hermetic; #[path = "prebuilt_common/mod.rs"] mod prebuilt_common; @@ -403,18 +405,14 @@ impl Repo { } } -/// Run the binary with ambient `SOCKET_*` scrubbed; `(code, stdout, stderr)`. +/// Run the binary with the hermetic `SOCKET_*` environment (ambient values +/// scrubbed, config layer, update check and telemetry forced off); +/// `(code, stdout, stderr)`. fn run_cli(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> (i32, String, String) { - let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_socket-patch")); + let mut cmd = hermetic::binary_command(); cmd.args(args).current_dir(cwd); - for (key, _) in std::env::vars() { - if key.starts_with("SOCKET_") && key != "SOCKET_NO_CONFIG" { - cmd.env_remove(key); - } - } - cmd.env_remove("GIT_CEILING_DIRECTORIES") - .env_remove("VIRTUAL_ENV"); - cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); + hermetic::scrub_extra(&mut cmd, &[hermetic::Extra::Venv, hermetic::Extra::Bundle]); + cmd.env_remove("GIT_CEILING_DIRECTORIES"); // The fixture's hosted pins name this origin; it makes them recorded. cmd.env("SOCKET_PATCH_SERVER_URL", "http://patch.test"); let absent = cwd.join(".absent-npm-config"); diff --git a/crates/socket-patch-cli/tests/spawn_env_hygiene.rs b/crates/socket-patch-cli/tests/spawn_env_hygiene.rs index d2a76309c..00a33bd4a 100644 --- a/crates/socket-patch-cli/tests/spawn_env_hygiene.rs +++ b/crates/socket-patch-cli/tests/spawn_env_hygiene.rs @@ -29,14 +29,11 @@ use serial_test::serial; /// children see whatever `SOCKET_*` the ambient shell exports unless the file /// scrubs by hand. const PENDING_RAW_SPAWNS: &[&str] = &[ - "apply/apply_network.rs", "e2e_hosted_production.rs", "e2e_maven.rs", "e2e_npm.rs", "e2e_redirect_bun_build.rs", "e2e_redirect_cargo_shapes.rs", - "e2e_scan.rs", - "e2e_socket_yml_policy.rs", "e2e_vendored_production.rs", "e2e_vex_vendor.rs", "rollback/rollback_multicopy_blob_gate.rs", From 88407b95577d5e9d19677bedd68fba060200dd58 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 02:05:35 +0000 Subject: [PATCH 3/3] Keep network and telemetry guards in apply tests The shared builder forces telemetry off and removes the API token. The offline airgap test then no longer showed that --offline alone keeps telemetry away from the mock. It turns telemetry back on, so it fails again if offline runs ever POST telemetry (checked by removing the offline term from is_telemetry_disabled). The cached-blob test only pointed SOCKET_API_URL at a dead port. With no token the child uses the public proxy, so a regressed fetch could reach the real proxy and still pass. Pin SOCKET_PROXY_URL too. The pypi test uses Extra::Venv instead of a hand-written VIRTUAL_ENV removal. Refs #823 Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/apply/apply_network.rs | 32 ++++++++++--------- crates/socket-patch-cli/tests/e2e_scan.rs | 5 +-- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/crates/socket-patch-cli/tests/apply/apply_network.rs b/crates/socket-patch-cli/tests/apply/apply_network.rs index 845f21120..379af2b57 100644 --- a/crates/socket-patch-cli/tests/apply/apply_network.rs +++ b/crates/socket-patch-cli/tests/apply/apply_network.rs @@ -585,14 +585,13 @@ async fn apply_pypi_package_uses_python_crawler() { // Run apply restricted to pypi. With the venv staged on disk and the // after-blob pre-cached, this must locate the package via the python // crawler and patch it — exercising the pypi dispatch branch end to - // end, not just "without panicking". `VIRTUAL_ENV` is cleared so an - // ambient venv in CI can't redirect discovery away from our `.venv`. - let out = hermetic::command(&binary()) - .args(["apply", "--json", "--offline", "--ecosystems", "pypi"]) - .current_dir(tmp.path()) - .env_remove("VIRTUAL_ENV") - .output() - .expect("run socket-patch"); + // end, not just "without panicking". An ambient venv is scrubbed so CI + // can't redirect discovery away from our `.venv`. + let mut cmd = hermetic::command(&binary()); + cmd.args(["apply", "--json", "--offline", "--ecosystems", "pypi"]) + .current_dir(tmp.path()); + hermetic::scrub_extra(&mut cmd, &[hermetic::Extra::Venv]); + let out = cmd.output().expect("run socket-patch"); let code = out.status.code().unwrap_or(-1); let stdout = String::from_utf8_lossy(&out.stdout).to_string(); let stderr = String::from_utf8_lossy(&out.stderr).to_string(); @@ -648,14 +647,14 @@ async fn apply_uses_locally_cached_blob_without_fetching() { // No mock server. If apply tries to hit the network, the test will // fail (connection refused) — proving the local-blob fast path is - // taken when sources are already on disk. + // taken when sources are already on disk. Both URLs are pinned: with + // no token the child takes the public-proxy path. let out = hermetic::command(&binary()) .args(["apply", "--json"]) .current_dir(tmp.path()) - .env( - "SOCKET_API_URL", - "http://127.0.0.1:1", // unreachable port — should never be contacted - ) + // Unreachable port: should never be contacted. + .env("SOCKET_API_URL", "http://127.0.0.1:1") + .env("SOCKET_PROXY_URL", "http://127.0.0.1:1") .output() .expect("run socket-patch"); let code = out.status.code().unwrap_or(-1); @@ -818,8 +817,11 @@ async fn offline_apply_with_token_makes_zero_network_requests() { // skip the auto-resolution and make this test vacuously green) and // keeps the config layer off; seed only what the scenario needs: token // set, org absent, both URLs pinned to the mock so any traffic — API or - // proxy path — is captured. - cmd.env("SOCKET_API_URL", mock.uri()) + // proxy path — is captured. Telemetry is switched back on (the builder + // forces it off) so only `--offline` can keep the telemetry POST away + // from the mock. + cmd.env("SOCKET_TELEMETRY_DISABLED", "0") + .env("SOCKET_API_URL", mock.uri()) .env("SOCKET_PROXY_URL", mock.uri()) .env("SOCKET_API_TOKEN", "fake-token-for-test"); let out = cmd.output().expect("run socket-patch"); diff --git a/crates/socket-patch-cli/tests/e2e_scan.rs b/crates/socket-patch-cli/tests/e2e_scan.rs index 4d9a79a1e..c1a64d548 100644 --- a/crates/socket-patch-cli/tests/e2e_scan.rs +++ b/crates/socket-patch-cli/tests/e2e_scan.rs @@ -96,8 +96,9 @@ fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { // The shared builder scrubs every ambient `SOCKET_*` (an ambient // SOCKET_DRY_RUN would turn every `scan --mode agent` into a preview, // SOCKET_GLOBAL would aim mutations at the host's real global - // node_modules) and forces the config layer and update check off; - // removing SOCKET_API_TOKEN also forces the public proxy (free-tier). + // node_modules) and forces the config layer, update check and telemetry + // off; removing SOCKET_API_TOKEN also forces the public proxy + // (free-tier). let mut cmd = hermetic::command(&binary()); cmd.args(args).current_dir(cwd); let out: Output = cmd.output().expect("failed to execute socket-patch binary");