diff --git a/docs/egress.md b/docs/egress.md index 9c862c5..76abcdf 100644 --- a/docs/egress.md +++ b/docs/egress.md @@ -19,11 +19,16 @@ bound to that sandbox's identity by the socket path — no in-band token. The proxy evaluates the policy, injects the matched rule's secret host-side, dials the origin, and relays the response. -A sandbox reaches it as a standard HTTP proxy: +The base image sets `http_proxy`/`https_proxy`/`no_proxy` on silkd's unit, and +silkd forwards exactly those variables into every exec — only when the guest +has no NIC beyond `lo`/`sit0`, so a lane with a working NIC is never steered +into a relay whose host side is closed. An unconfigured client just works, and +`-x` still does: ```sh -curl -x http://127.0.0.1:3128 https://api.github.com/user # allowed + credentialed -curl -x http://127.0.0.1:3128 https://evil.example/ # 403 egress denied: evil.example +curl https://api.github.com/user # allowed + credentialed +curl https://evil.example/ # 403 egress denied: evil.example +curl -x http://127.0.0.1:3128 https://api.github.com/user # explicit form, same path ``` ## How it works (egress lane) @@ -54,6 +59,21 @@ IPv6 forms (NAT64, 6to4, Teredo, IPv4-compatible) — so an allow-listed host that resolves, or is rebound, to one cannot reach the sandboxd host or a sibling VM. +`egress_internal_allow` re-admits named prefixes through that guard for nodes +whose sandboxes legitimately need internal services: + +```jsonc +{ "egress_internal_allow": ["10.8.0.0/16", "fdc8::/16"] } +``` + +It is node-wide (every pool and tenant on the node gets the same re-admission) +and checked after NAT64 unwrapping, so an embedded IPv4 matches as the IPv4 it +is. Name service prefixes, never the whole private space: the guest bridges are +themselves ULA/RFC1918, so a blanket permit would open sandbox-to-sandbox and +the host's own gateway. `0.0.0.0/0` + `::/0` turns the guard off entirely — for +fleets with a policy-enforcing proxy in front — and requests still pass the +domain policy first; the allow-list widens the IP gate only. + ### Deployment constraints - **One sandboxd per host.** The restart sweep owns the whole `sandbox_egress_*` diff --git a/docs/silkd.md b/docs/silkd.md index 035adf6..51b1705 100644 --- a/docs/silkd.md +++ b/docs/silkd.md @@ -65,6 +65,9 @@ lane. `SILKD_NET=none|egress` overrides the probe for tests and operators. On every lane silkd also binds `127.0.0.1:3128` and relays each connection to the host's [guarded-egress](egress.md) proxy over vsock (`CID2:2049`); when the host wired no policy the per-connection dial is refused, so the port is inert. +On the no-network lane silkd forwards the image-baked proxy variables +(`http_proxy` and friends) into every exec, so unconfigured clients use the +relay without being told; a lane with its own network never gets them. ## Limits diff --git a/os-image/android/silkd.rc b/os-image/android/silkd.rc index 7e3ad45..0028d1e 100644 --- a/os-image/android/silkd.rc +++ b/os-image/android/silkd.rc @@ -10,6 +10,10 @@ service silkd /system/bin/silkd user root group root setenv SILKD_PORT 2048 + # Mirrors the base:24.04 unit; forwarded into execs only on the no-NIC lane. + setenv http_proxy http://127.0.0.1:3128 + setenv https_proxy http://127.0.0.1:3128 + setenv no_proxy localhost,127.0.0.1,::1 on boot start silkd diff --git a/os-image/base/24.04/Dockerfile b/os-image/base/24.04/Dockerfile index fc2c021..a4aad56 100644 --- a/os-image/base/24.04/Dockerfile +++ b/os-image/base/24.04/Dockerfile @@ -54,6 +54,10 @@ RUN --mount=type=secret,id=sandbox_install_agent \ # [silkd] product-tier daemon on vsock 2048, started at sysinit alongside # the agent — this is the sandbox product's readiness signal. Same # measured-fast ordering as the agent unit (no local-fs wait). + # + # 3128 is silkd's loopback relay over vsock to the host proxy, the none + # lane's only way out. silkd forwards these into execs only on the + # no-network lane, so a lane with its own NIC never dials a closed relay. printf '%s\n' \ '[Unit]' \ 'Description=silkd (sandbox product daemon: exec, files, sessions over vsock)' \ @@ -65,6 +69,9 @@ RUN --mount=type=secret,id=sandbox_install_agent \ 'Type=simple' \ 'ExecStart=/usr/local/bin/silkd' \ 'Environment=SILKD_PORT=2048' \ + 'Environment=http_proxy=http://127.0.0.1:3128' \ + 'Environment=https_proxy=http://127.0.0.1:3128' \ + 'Environment=no_proxy=localhost,127.0.0.1,::1' \ 'Restart=always' \ 'RestartSec=2s' \ 'LimitNOFILE=65536' \ diff --git a/sandboxd/config/config.go b/sandboxd/config/config.go index 574e7a1..7253169 100644 --- a/sandboxd/config/config.go +++ b/sandboxd/config/config.go @@ -10,6 +10,7 @@ import ( "encoding/json" "fmt" "net" + "net/netip" "os" "runtime" "slices" @@ -231,6 +232,11 @@ type Config struct { // revocation bound for a replica a delete broadcast missed). Off by default. CheckpointPeerHeal bool `json:"checkpoint_peer_heal,omitempty"` + // EgressInternalAllow re-admits CIDRs through the proxy's SSRF guard, + // node-wide (every pool and tenant). Prefixes, not a permit-private + // switch: the guest bridges are themselves ULA/RFC1918. + EgressInternalAllow []string `json:"egress_internal_allow,omitempty"` + // CheckpointTTLHours ages out checkpoints (0 = keep forever); the // sweep runs hourly and on startup. CheckpointTTLHours int `json:"checkpoint_ttl_hours,omitempty"` @@ -380,6 +386,9 @@ func (c *Config) validate() error { if c.CheckpointPeerHeal && c.APIToken == "" { return fmt.Errorf("checkpoint_peer_heal requires api_token: without it resolveScope leaves the raw checkpoint blob GET reachable with no credential") } + if err := c.validateEgressAllow(); err != nil { + return err + } if err := c.validateTenants(); err != nil { return err } @@ -448,6 +457,17 @@ func (c *Config) validateSecrets() (map[string]struct{}, error) { return names, nil } +// validateEgressAllow rejects a malformed prefix at load: a typo would +// otherwise surface as a refused dial long after startup. +func (c *Config) validateEgressAllow() error { + for _, cidr := range c.EgressInternalAllow { + if _, err := netip.ParsePrefix(cidr); err != nil { + return fmt.Errorf("egress_internal_allow %q: %w", cidr, err) + } + } + return nil +} + func (c *Config) validateTenants() error { if len(c.Tenants) > 0 && c.APIToken == "" { return fmt.Errorf("tenants require api_token: the operator surfaces are unreachable without it") diff --git a/sandboxd/pool/egress.go b/sandboxd/pool/egress.go index 290f141..f22622f 100644 --- a/sandboxd/pool/egress.go +++ b/sandboxd/pool/egress.go @@ -46,9 +46,13 @@ var ( netip.MustParsePrefix("3fff::/20"), // documentation netip.MustParsePrefix("5f00::/16"), // SRv6 SIDs } +) - // egressDialer blocks internal-address targets so the proxy cannot be an SSRF. - egressDialer = &net.Dialer{Control: func(_, address string, _ syscall.RawConn) error { +// newEgressDialer builds the proxy's upstream dialer: internal targets are +// blocked (the proxy must not be an SSRF), then exactly the node-named +// prefixes re-admitted. +func newEgressDialer(allow []netip.Prefix) *net.Dialer { + return &net.Dialer{Control: func(_, address string, _ syscall.RawConn) error { host, _, err := net.SplitHostPort(address) if err != nil { return err @@ -62,13 +66,18 @@ var ( b := ip.As16() ip = netip.AddrFrom4([4]byte{b[12], b[13], b[14], b[15]}) } + // After the NAT64 unwrap (a v4-in-v6 target matches as the v4 it is), + // before the block, so a named prefix wins. + if slices.ContainsFunc(allow, func(p netip.Prefix) bool { return p.Contains(ip) }) { + return nil + } if !ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || slices.ContainsFunc(internalRanges, func(p netip.Prefix) bool { return p.Contains(ip) }) { return fmt.Errorf("egress: blocked internal address %s", ip) } return nil }} -) +} // egressListener is one sandbox's egress accept point: an http.Server serving // egress.Proxy over the per-sandbox UDS the VMM connects when the guest dials @@ -223,3 +232,15 @@ func (m *Manager) effectivePolicy(sb *types.Sandbox) (egress.Evaluator, bool) { return nil, false } } + +// parsePrefixes turns the allow-list into prefixes; config validation already +// rejected malformed entries, so a leftover is dropped rather than failing dials. +func parsePrefixes(cidrs []string) []netip.Prefix { + out := make([]netip.Prefix, 0, len(cidrs)) + for _, c := range cidrs { + if p, err := netip.ParsePrefix(c); err == nil { + out = append(out, p) + } + } + return out +} diff --git a/sandboxd/pool/egress_test.go b/sandboxd/pool/egress_test.go index eada552..957cff9 100644 --- a/sandboxd/pool/egress_test.go +++ b/sandboxd/pool/egress_test.go @@ -426,13 +426,13 @@ func TestEgressDialerBlocksInternal(t *testing.T) { "[64:ff9b::a00:1]:80", // NAT64-embedded 10.0.0.1 } for _, addr := range blocked { - if err := egressDialer.Control("tcp", addr, nil); err == nil { + if err := newEgressDialer(nil).Control("tcp", addr, nil); err == nil { t.Errorf("dial to internal %s allowed; SSRF not blocked", addr) } } // public v4 direct, public v6, and a public v4 via the NAT64 well-known prefix for _, addr := range []string{"93.184.216.34:443", "[2606:4700:4700::1111]:443", "[64:ff9b::5db8:d822]:443"} { - if err := egressDialer.Control("tcp", addr, nil); err != nil { + if err := newEgressDialer(nil).Control("tcp", addr, nil); err != nil { t.Errorf("dial to public %s blocked: %v", addr, err) } } @@ -455,6 +455,61 @@ func TestEgressLaneCannotForkOrCheckpoint(t *testing.T) { } } +// TestEgressDialerAdmitsOnlyNamedInternalPrefixes pins the SSRF boundary: +// named prefixes re-admit corporate services without re-admitting the guest +// bridges, which are themselves ULA/RFC1918. +func TestEgressDialerAdmitsOnlyNamedInternalPrefixes(t *testing.T) { + d := newEgressDialer(parsePrefixes([]string{"fdc8::/16", "10.8.0.0/16"})) + check := func(addr string) error { + return d.Control("tcp", addr, nil) + } + for name, tc := range map[string]struct { + addr string + allowed bool + }{ + "public v4": {"93.184.216.34:443", true}, + "public v6": {"[2606:2800:220:1:248:1893:25c8:1946]:443", true}, + "corporate v6": {"[fdc8:17:9:200f::1]:443", true}, + "corporate v4": {"10.8.7.149:443", true}, + // Guests and the host gateway share fc00::/7 with the corporate range. + "another sandbox on the bridge": {"[fd00:c0c0:38::5]:443", false}, + "the host's bridge gateway": {"[fd00:c0c0:38::1]:443", false}, + "other private v4": {"192.168.1.1:443", false}, + "loopback": {"127.0.0.1:443", false}, + "cloud metadata": {"169.254.169.254:80", false}, + "CGNAT metadata": {"100.100.100.200:80", false}, + } { + t.Run(name, func(t *testing.T) { + err := check(tc.addr) + if tc.allowed && err != nil { + t.Errorf("%s was blocked: %v", tc.addr, err) + } + if !tc.allowed && err == nil { + t.Errorf("%s was allowed; it must not be reachable from a guest", tc.addr) + } + }) + } +} + +// TestEgressDialerWildcardAllowsEverything pins the fully-open form as a +// deliberate choice — loopback and cloud metadata included — for fleets with +// a policy-enforcing forward proxy in front. +func TestEgressDialerWildcardAllowsEverything(t *testing.T) { + d := newEgressDialer(parsePrefixes([]string{"0.0.0.0/0", "::/0"})) + for _, addr := range []string{ + "93.184.216.34:443", // public + "[fdc8:17:9:200f::1]:443", // corporate v6 + "10.8.7.149:443", // corporate v4 + "[fd00:c0c0:38::5]:443", // another sandbox + "127.0.0.1:7777", // the node itself + "169.254.169.254:80", // cloud metadata + } { + if err := d.Control("tcp", addr, nil); err != nil { + t.Errorf("%s blocked under a wildcard allow-list: %v", addr, err) + } + } +} + // egressClient builds an HTTP client that reaches the origin through the // per-sandbox egress proxy UDS, exactly as the guest's proxy client would over // vsock. Playing the VMM's role lets the host-side path run without a real VM. diff --git a/sandboxd/pool/pool.go b/sandboxd/pool/pool.go index 5a9f259..9f76ab7 100644 --- a/sandboxd/pool/pool.go +++ b/sandboxd/pool/pool.go @@ -384,7 +384,7 @@ func NewManager(ctx context.Context, cfg *config.Config, eng Engine, secrets *eg healPending: map[string]struct{}{}, healAbort: map[string]struct{}{}, egressSecrets: secrets, - dial: egressDialer.DialContext, + dial: newEgressDialer(parsePrefixes(cfg.EgressInternalAllow)).DialContext, sweep: netfilter.SweepExcept, refillSem: make(chan struct{}, refill), probeSem: make(chan struct{}, refill), diff --git a/silkd/src/git.rs b/silkd/src/git.rs index 1019e0f..ba1f830 100644 --- a/silkd/src/git.rs +++ b/silkd/src/git.rs @@ -10,6 +10,7 @@ use tokio::io::AsyncWrite; use tokio::process::Command; use crate::proto::{self, ErrorKind, GitBranchOp, GitFileStatus, Response}; +use crate::sysutil; /// Clones `url` into `path` (optionally a branch, shallow depth), then Done. pub async fn clone( @@ -169,6 +170,7 @@ async fn net_verb( /// exec could otherwise scrape the token. fn git_cmd(dir: &str, auth: Option<&str>) -> Command { let mut cmd = Command::new("git"); + sysutil::align_proxy_env(&mut cmd); cmd.arg("-C").arg(dir); apply_config(&mut cmd, auth); cmd.stdin(Stdio::null()) diff --git a/silkd/src/lsp.rs b/silkd/src/lsp.rs index 2f24cc0..a7738c9 100644 --- a/silkd/src/lsp.rs +++ b/silkd/src/lsp.rs @@ -26,6 +26,7 @@ use tokio::process::{Child, ChildStdin, ChildStdout, Command}; use tokio::sync::mpsc; use crate::proto::{self, ErrorKind, Request, Response}; +use crate::sysutil; const MANIFEST_DIR: &str = "/etc/silkd/lsp.d"; @@ -62,6 +63,7 @@ impl Broker { } }; let mut cmd = Command::new(&argv[0]); + sysutil::align_proxy_env(&mut cmd); cmd.args(&argv[1..]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) diff --git a/silkd/src/net.rs b/silkd/src/net.rs index 2112295..30a1824 100644 --- a/silkd/src/net.rs +++ b/silkd/src/net.rs @@ -1,21 +1,30 @@ //! Guest network-lane detection. The egress lane has a device-backed NIC; //! the none lane carries only virtual interfaces (lo, plus the tunnels an -//! all-builtin kernel auto-creates). Verbs that need the network (git -//! clone/push/pull) consult this so the none lane fails fast with a typed -//! error instead of hanging on an unreachable remote. +//! all-builtin kernel auto-creates). Network verbs (git clone/push/pull) and +//! proxy-env forwarding consult this. + +use std::sync::OnceLock; +use std::sync::atomic::{AtomicI8, Ordering}; + +static LANE_OVERRIDE: AtomicI8 = AtomicI8::new(-1); /// Reports whether the guest can reach a network. `SILKD_NET` overrides the -/// probe (`none` / `egress`) for operators and tests; otherwise an interface -/// under /sys/class/net backed by a real device (a `device` symlink) counts. +/// probe (`none` / `egress`) for operators; otherwise an interface under +/// /sys/class/net backed by a real device (a `device` symlink) counts. /// Name filtering is not enough: the all-builtin sandbox kernel auto-creates /// virtual tunnels (sit0 and friends) even on the no-NIC lane, and `lo` is /// virtual too — only a virtio/physical NIC has a device backing. On /// non-Linux dev hosts (no /sys/class/net) it defaults to true so git verbs /// are testable. pub fn has_egress() -> bool { - match std::env::var("SILKD_NET").as_deref() { - Ok("none") => return false, - Ok("egress") => return true, + match LANE_OVERRIDE.load(Ordering::Relaxed) { + 0 => return false, + 1 => return true, + _ => {} + } + match silkd_net() { + 0 => return false, + 1 => return true, _ => {} } let Ok(entries) = std::fs::read_dir("/sys/class/net") else { @@ -23,3 +32,18 @@ pub fn has_egress() -> bool { }; entries.flatten().any(|e| e.path().join("device").exists()) } + +/// Lane override for tests: set_var would race every concurrent getenv. +pub fn override_egress_for_tests(lane: Option) { + LANE_OVERRIDE.store(lane.map_or(-1, i8::from), Ordering::Relaxed); +} + +/// SILKD_NET decoded once: operator config, fixed at service start. +fn silkd_net() -> i8 { + static SILKD_NET: OnceLock = OnceLock::new(); + *SILKD_NET.get_or_init(|| match std::env::var("SILKD_NET").as_deref() { + Ok("none") => 0, + Ok("egress") => 1, + _ => -1, + }) +} diff --git a/silkd/src/sysutil.rs b/silkd/src/sysutil.rs index ea96b1a..2d8d31b 100644 --- a/silkd/src/sysutil.rs +++ b/silkd/src/sysutil.rs @@ -6,8 +6,8 @@ use std::fmt::Write as _; use std::io::Read; use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}; use std::process::ExitStatus; -use std::sync::Mutex; use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Mutex, OnceLock}; use tokio::process::Command; @@ -17,6 +17,17 @@ static NSS_LOCK: Mutex<()> = Mutex::new(()); static TMP_SEQ: AtomicU64 = AtomicU64::new(0); +/// Forwarded from silkd's own environment into every exec: the loopback proxy +/// relay is the no-NIC lane's only way out, and nothing else may leak. +const FORWARDED: [&str; 6] = [ + "http_proxy", + "https_proxy", + "no_proxy", + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", +]; + /// A suffix unique within this process (pid + monotonic counter), enough to /// name a temp file a concurrent write in the same directory won't collide /// with. Not cryptographic — just unique. @@ -82,16 +93,57 @@ fn valid_pid(id: u32) -> bool { id != 0 && id <= i32::MAX as u32 } -/// The environment every exec starts from before the request's env is -/// layered on — a sane PATH and TERM, nothing inherited from silkd. -pub fn base_env() -> [(&'static str, &'static str); 2] { - [ +/// The environment every exec starts from before the request's env is layered +/// on — a sane PATH and TERM, plus the proxy snapshot on the no-network lane +/// (a lane with its own NIC would be steered into a closed relay). +pub fn base_env() -> Vec<(&'static str, &'static str)> { + compose_env(crate::net::has_egress(), proxy_vars()) +} + +/// Applies base_env's lane rule to an env-inheriting child (git, language +/// servers): with a network of its own, the proxy variables come off. +pub fn align_proxy_env(cmd: &mut Command) { + align_proxy_env_for(cmd, crate::net::has_egress()); +} + +fn align_proxy_env_for(cmd: &mut Command, nic: bool) { + if !nic { + return; + } + for key in FORWARDED { + cmd.env_remove(key); + } +} + +fn compose_env<'a>(nic: bool, proxy: &'a [(&'static str, String)]) -> Vec<(&'static str, &'a str)> { + let mut env = vec![ ( "PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", ), ("TERM", "xterm-256color"), - ] + ]; + if !nic { + env.extend(proxy.iter().map(|(k, v)| (*k, v.as_str()))); + } + env +} + +/// The FORWARDED values present in silkd's own environment, read once: the +/// unit environment is fixed at service start and silkd never mutates it. +fn proxy_vars() -> &'static [(&'static str, String)] { + static PROXY_VARS: OnceLock> = OnceLock::new(); + PROXY_VARS.get_or_init(|| snapshot_proxy(|key| std::env::var(key).ok())) +} + +fn snapshot_proxy(get: impl Fn(&str) -> Option) -> Vec<(&'static str, String)> { + FORWARDED + .iter() + .filter_map(|&key| match get(key) { + Some(v) if !v.is_empty() => Some((key, v)), + _ => None, + }) + .collect() } /// Resolves a username to uid/gid via getpwnam and sets them on the command. @@ -267,6 +319,58 @@ mod tests { assert!(env.iter().any(|(k, _)| *k == "TERM")); } + /// The no-NIC lane only reaches out if the proxy variables arrive without + /// anyone asking; nothing else of silkd's environment is the guest's. + #[test] + fn base_env_forwards_only_the_proxy_variables() { + let proxy = snapshot_proxy(|key| match key { + "http_proxy" => Some("http://127.0.0.1:3128".to_string()), + "HTTPS_PROXY" => Some(String::new()), + _ => Some("2048".to_string()), + }); + let env = compose_env(false, &proxy); + assert_eq!( + env.iter() + .find(|(k, _)| *k == "http_proxy") + .map(|(_, v)| *v), + Some("http://127.0.0.1:3128"), + ); + assert!( + !env.iter().any(|(k, _)| *k == "SILKD_PORT"), + "silkd's own settings must not reach the guest's commands", + ); + assert!( + !env.iter().any(|(k, _)| *k == "HTTPS_PROXY"), + "an empty variable must not be forwarded", + ); + } + + /// A guest with a working NIC must not be steered into the loopback relay, + /// which is closed unless the host armed a proxy for this sandbox. + #[test] + fn base_env_forwards_nothing_when_a_nic_is_present() { + let proxy = snapshot_proxy(|_| Some("http://127.0.0.1:3128".to_string())); + let env = compose_env(true, &proxy); + assert!( + !env.iter().any(|(k, _)| *k == "http_proxy"), + "proxy variables must stay off a lane with its own NIC", + ); + } + + /// Env-inheriting children (git, language servers) follow the same lane + /// rule as cleared ones: scrubbed with a NIC, inherited without. + #[test] + fn align_proxy_env_scrubs_only_on_a_nic_lane() { + let removed = |cmd: &Command| cmd.as_std().get_envs().filter(|(_, v)| v.is_none()).count(); + let mut with_nic = Command::new("true"); + align_proxy_env_for(&mut with_nic, true); + assert_eq!(removed(&with_nic), FORWARDED.len()); + + let mut without_nic = Command::new("true"); + align_proxy_env_for(&mut without_nic, false); + assert_eq!(removed(&without_nic), 0); + } + #[test] fn unknown_user_rejected() { assert!(lookup_user("definitely-not-a-user-xyz").is_err()); diff --git a/silkd/tests/git_e2e.rs b/silkd/tests/git_e2e.rs index 6262a65..c4847af 100644 --- a/silkd/tests/git_e2e.rs +++ b/silkd/tests/git_e2e.rs @@ -1,5 +1,5 @@ //! git verb E2E against a real git binary in temp repos. Local verbs run on -//! any host; the none-lane guard is exercised via SILKD_NET. +//! any host; the none-lane guard is exercised via the test lane override. #![allow(clippy::unwrap_used, clippy::expect_used)] // test code, like #[cfg(test)] mod common; @@ -113,14 +113,11 @@ async fn commit_failure_surfaces_git_error() { assert_eq!(type_of(&frames[0]), "error"); } -// One test owns SILKD_NET so the two lane cases can't race a shared env var -// against parallel test threads. +// One test owns the lane override so the two cases cannot fight over it. #[tokio::test] async fn clone_lane_behavior() { // none lane refuses with a typed error pointing at fs.push. - // SAFETY: this test is the binary's only SILKD_NET accessor (see the - // ownership note above), so no thread races the mutation. - unsafe { std::env::set_var("SILKD_NET", "none") }; + silkd::net::override_egress_for_tests(Some(false)); let none = exchange(&[json!({ "op":"git_clone","url":"https://example.com/x.git","path":"/tmp/silkd-x" }) @@ -137,17 +134,13 @@ async fn clone_lane_behavior() { git(src.path(), &["commit", "-qm", "init"]); let dst = tempfile::tempdir().unwrap(); let target = dst.path().join("clone"); - // SAFETY: this test is the binary's only SILKD_NET accessor (see the - // ownership note above), so no thread races the mutation. - unsafe { std::env::set_var("SILKD_NET", "egress") }; + silkd::net::override_egress_for_tests(Some(true)); let ok = exchange(&[json!({ "op":"git_clone", "url": src.path().to_str().unwrap(), "path": target.to_str().unwrap() }) .to_string()]) .await; - // SAFETY: this test is the binary's only SILKD_NET accessor (see the - // ownership note above), so no thread races the mutation. - unsafe { std::env::remove_var("SILKD_NET") }; + silkd::net::override_egress_for_tests(None); assert_eq!(type_of(last(&ok)), "done", "clone failed: {ok:?}"); assert!(target.join("r.txt").exists()); }