diff --git a/src/Cargo.lock b/src/Cargo.lock index aab268823..abc530c3c 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -73,7 +73,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -84,7 +84,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -486,7 +486,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -513,7 +513,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -837,14 +837,14 @@ dependencies = [ "vmm-sys-util", "windows", "windows-result", - "windows-sys", + "windows-sys 0.61.2", "windows-version", ] [[package]] name = "hyperlight-unikraft-host" version = "0.1.0" -source = "git+https://github.com/hyperlight-dev/hyperlight-unikraft?branch=main#8affbccb1f7a2f9aa0c8e0aea08c82f76b45bbe5" +source = "git+https://github.com/hyperlight-dev/hyperlight-unikraft?branch=main#0eb9887fbccf338208efe37101b04e7ffa204424" dependencies = [ "anyhow", "base64", @@ -854,7 +854,8 @@ dependencies = [ "memmap2", "nix", "serde_json", - "windows-sys", + "socket2 0.5.10", + "windows-sys 0.61.2", ] [[package]] @@ -1262,7 +1263,7 @@ checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1661,7 +1662,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1829,6 +1830,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.3" @@ -1836,7 +1847,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1909,7 +1920,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1982,9 +1993,9 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.3", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2279,7 +2290,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2389,6 +2400,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -2398,6 +2418,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + [[package]] name = "windows-threading" version = "0.2.1" @@ -2416,6 +2452,54 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/src/wxc_common/src/hyperlight_runner.rs b/src/wxc_common/src/hyperlight_runner.rs index 37a3f52e6..165a209e9 100644 --- a/src/wxc_common/src/hyperlight_runner.rs +++ b/src/wxc_common/src/hyperlight_runner.rs @@ -12,6 +12,7 @@ //! | Script delivery | Direct `Runtime::run_code(&str)` | //! | Cold start | Snapshot restore (~50–60 ms) | //! | Filesystem | Host dir mounts via `Preopen` | +//! | Networking | Host-proxied sockets via `NetworkPolicy` | //! | Script I/O | Host's stdout/stderr (host_print) | //! | stdlib coverage | Full CPython + preloaded ML stack (numpy, pandas, etc.) | //! @@ -75,7 +76,7 @@ use crate::models::{CodexRequest, NetworkPolicy, ScriptResponse}; use crate::script_runner::ScriptRunner; use hyperlight_unikraft::pyhl; -use hyperlight_unikraft::Preopen; +use hyperlight_unikraft::{AllowList, BlockList, Preopen}; // -- Error classification ---------------------------------------------------- @@ -126,10 +127,7 @@ const KERNEL_FILE: &str = "kernel"; const INITRD_FILE: &str = "initrd.cpio"; const SNAPSHOT_FILE: &str = "snapshot.hls"; -const ERR_NETWORK_POLICY: &str = - "network policy is not supported by the hyperlight backend -- guest has no network stack"; -const ERR_PROXY_POLICY: &str = - "network proxy is not supported by the hyperlight backend -- guest has no network stack"; +const ERR_PROXY_POLICY: &str = "network proxy is not supported by the hyperlight backend"; const ERR_WORKDIR: &str = "workingDirectory is not supported by the hyperlight backend -- guest has its own filesystem namespace"; const ERR_NO_INSTALL_SOURCE: &str = @@ -150,6 +148,8 @@ pub struct HyperlightScriptRunner { runtime: Option, active_home: Option, active_preopens: Vec, + active_network_hosts: Vec, + active_network_default: NetworkPolicy, } impl Default for HyperlightScriptRunner { @@ -202,6 +202,7 @@ pub fn setup(force: bool, logger: &mut Logger) -> Result { home: &home, source: pyhl::InstallSource::Ghcr, mounts: &[], + network: None, force, }; let report = pyhl::install(&opts).map_err(|e| format!("hyperlight install: {e:#}"))?; @@ -218,6 +219,8 @@ impl HyperlightScriptRunner { runtime: None, active_home: None, active_preopens: Vec::new(), + active_network_hosts: Vec::new(), + active_network_default: NetworkPolicy::default(), } } @@ -269,20 +272,19 @@ impl HyperlightScriptRunner { } /// Reject only policies that the hyperlight backend genuinely cannot honor. - /// Filesystem mounts ARE supported — translated to Preopens below. + /// Filesystem mounts and network policies ARE supported. fn validate_policies(request: &CodexRequest) -> Result<(), PyhlError> { - if !request.policy.allowed_hosts.is_empty() - || !request.policy.blocked_hosts.is_empty() - || request.policy.default_network_policy != NetworkPolicy::Allow - { - return Err(PyhlError::Preflight(ERR_NETWORK_POLICY.to_string())); - } if request.policy.network_proxy.is_enabled() { return Err(PyhlError::Preflight(ERR_PROXY_POLICY.to_string())); } if !request.working_directory.is_empty() { return Err(PyhlError::Preflight(ERR_WORKDIR.to_string())); } + if !request.policy.allowed_hosts.is_empty() && !request.policy.blocked_hosts.is_empty() { + return Err(PyhlError::Preflight( + "allowedHosts and blockedHosts are mutually exclusive".to_string(), + )); + } // Denied paths: block early if any appears in the allow lists. // Also reject a config that only specifies denies — there's no @@ -308,6 +310,35 @@ impl HyperlightScriptRunner { Ok(()) } + /// Translate MXC's network policy fields into a pyhl `NetworkPolicy`. + /// + /// - `default_network_policy == Block` → `None` (networking disabled) + /// - `allowed_hosts` non-empty → `AllowList` (only listed hosts reachable) + /// - `blocked_hosts` non-empty → `BlockList` (listed hosts denied, rest allowed) + /// - `default_network_policy == Allow`, no host lists → `AllowAll` + fn network_policy_from_request( + request: &CodexRequest, + ) -> Result, PyhlError> { + if request.policy.default_network_policy == NetworkPolicy::Block { + return Ok(None); + } + if !request.policy.allowed_hosts.is_empty() { + let allow_list = AllowList::from_hosts(&request.policy.allowed_hosts) + .map_err(|e| PyhlError::Preflight(format!("resolve allowed_hosts: {e:#}")))?; + return Ok(Some(hyperlight_unikraft::NetworkPolicy::AllowList( + allow_list, + ))); + } + if !request.policy.blocked_hosts.is_empty() { + let block_list = BlockList::from_hosts(&request.policy.blocked_hosts) + .map_err(|e| PyhlError::Preflight(format!("resolve blocked_hosts: {e:#}")))?; + return Ok(Some(hyperlight_unikraft::NetworkPolicy::BlockList( + block_list, + ))); + } + Ok(Some(hyperlight_unikraft::NetworkPolicy::AllowAll)) + } + /// Translate `ContainerPolicy.{readwrite,readonly}Paths` into /// `Preopen` entries. Each host path is exposed inside the guest at /// `/host/` — matches the `pyhl` CLI's `--mount ` default @@ -390,14 +421,22 @@ impl HyperlightScriptRunner { &mut self, home: &Path, preopens: Vec, + network: Option, + network_hosts: &[String], + network_default: NetworkPolicy, logger: &mut Logger, ) -> Result<&mut pyhl::Runtime, PyhlError> { let same_home = self.active_home.as_deref() == Some(home); let same_mounts = preopens_equal(&self.active_preopens, &preopens); + let mut sorted_hosts = network_hosts.to_vec(); + sorted_hosts.sort(); + sorted_hosts.dedup(); + let same_network = self.active_network_hosts == sorted_hosts + && self.active_network_default == network_default; // `if let Some(rt) = self.runtime.as_mut()` trips the borrow // checker because a later branch reassigns `self.runtime`. #[allow(clippy::unnecessary_unwrap)] - if same_home && same_mounts && self.runtime.is_some() { + if same_home && same_mounts && same_network && self.runtime.is_some() { return Ok(self.runtime.as_mut().unwrap()); } // Drop any prior runtime before rebuilding against new state. @@ -413,8 +452,6 @@ impl HyperlightScriptRunner { "hyperlight: no snapshot at {:?}; auto-installing from kernel + initrd", home.join(SNAPSHOT_FILE) )); - // Use `Explicit` to point at the files we know are present; - // avoids a scan and works regardless of surrounding layout. let kernel = home.join(KERNEL_FILE); let initrd = home.join(INITRD_FILE); let opts = pyhl::InstallOptions { @@ -424,6 +461,7 @@ impl HyperlightScriptRunner { initrd: &initrd, }, mounts: &preopens, + network: network.as_ref(), force: false, }; let report = pyhl::install(&opts) @@ -435,11 +473,13 @@ impl HyperlightScriptRunner { } logger.log_line(&format!("hyperlight: using image home {home:?}")); - let rt = pyhl::Runtime::new(home, &preopens) + let rt = pyhl::Runtime::new(home, &preopens, network.as_ref()) .map_err(|e| PyhlError::Runtime(format!("open hyperlight runtime: {e:#}")))?; self.runtime = Some(rt); self.active_home = Some(home.to_path_buf()); self.active_preopens = preopens; + self.active_network_hosts = sorted_hosts; + self.active_network_default = network_default; Ok(self.runtime.as_mut().unwrap()) } } @@ -464,8 +504,27 @@ impl ScriptRunner for HyperlightScriptRunner { return e.to_response(); } }; + let network = match Self::network_policy_from_request(request) { + Ok(n) => n, + Err(e) => { + logger.log_line(&e.to_string()); + return e.to_response(); + } + }; - let rt = match self.ensure_runtime(&home, preopens, logger) { + let network_hosts = if !request.policy.allowed_hosts.is_empty() { + &request.policy.allowed_hosts + } else { + &request.policy.blocked_hosts + }; + let rt = match self.ensure_runtime( + &home, + preopens, + network, + network_hosts, + request.policy.default_network_policy.clone(), + logger, + ) { Ok(rt) => rt, Err(e) => { logger.log_line(&e.to_string()); @@ -706,29 +765,68 @@ mod tests { } #[test] - fn policy_rejects_network_rules() { - let mut r = runner(); + fn network_policy_allow_all_when_default_allow() { + let request = CodexRequest::default(); + let policy = HyperlightScriptRunner::network_policy_from_request(&request).unwrap(); + assert!(matches!( + policy, + Some(hyperlight_unikraft::NetworkPolicy::AllowAll) + )); + } + + #[test] + fn network_policy_allowlist_from_allowed_hosts() { let request = CodexRequest { - script_code: "print('x')".to_string(), policy: ContainerPolicy { - allowed_hosts: vec!["example.com".to_string()], + allowed_hosts: vec!["127.0.0.1".to_string()], ..Default::default() }, ..Default::default() }; - let mut logger = Logger::new(Mode::Buffer); - let resp = r.run(&request, &mut logger); - assert_eq!(resp.exit_code, ERROR_EXIT_CODE); - assert!(resp.error_message.contains(ERR_NETWORK_POLICY)); + let policy = HyperlightScriptRunner::network_policy_from_request(&request).unwrap(); + assert!(matches!( + policy, + Some(hyperlight_unikraft::NetworkPolicy::AllowList(_)) + )); + } + + #[test] + fn network_policy_none_when_blocked() { + let request = CodexRequest { + policy: ContainerPolicy { + default_network_policy: NetworkPolicy::Block, + ..Default::default() + }, + ..Default::default() + }; + let policy = HyperlightScriptRunner::network_policy_from_request(&request).unwrap(); + assert!(policy.is_none()); } #[test] - fn policy_rejects_block_default_network() { + fn network_policy_blocklist_from_blocked_hosts() { + let request = CodexRequest { + policy: ContainerPolicy { + blocked_hosts: vec!["127.0.0.1".to_string()], + ..Default::default() + }, + ..Default::default() + }; + let policy = HyperlightScriptRunner::network_policy_from_request(&request).unwrap(); + assert!(matches!( + policy, + Some(hyperlight_unikraft::NetworkPolicy::BlockList(_)) + )); + } + + #[test] + fn policy_rejects_allowed_and_blocked_hosts() { let mut r = runner(); let request = CodexRequest { script_code: "print('x')".to_string(), policy: ContainerPolicy { - default_network_policy: NetworkPolicy::Block, + allowed_hosts: vec!["a.com".to_string()], + blocked_hosts: vec!["b.com".to_string()], ..Default::default() }, ..Default::default() @@ -736,7 +834,7 @@ mod tests { let mut logger = Logger::new(Mode::Buffer); let resp = r.run(&request, &mut logger); assert_eq!(resp.exit_code, ERROR_EXIT_CODE); - assert!(resp.error_message.contains(ERR_NETWORK_POLICY)); + assert!(resp.error_message.contains("mutually exclusive")); } #[test] diff --git a/src/wxc_e2e_tests/tests/e2e_windows.rs b/src/wxc_e2e_tests/tests/e2e_windows.rs index b911ece53..45c7ce0f5 100644 --- a/src/wxc_e2e_tests/tests/e2e_windows.rs +++ b/src/wxc_e2e_tests/tests/e2e_windows.rs @@ -602,6 +602,18 @@ fn hyperlight_suite() { expected_exit: 42, output_contains: None, }, + HyperlightCase { + config: "hyperlight_networking.json", + description: "HTTP GET with allowedHosts network policy", + expected_exit: 0, + output_contains: Some("200"), + }, + HyperlightCase { + config: "hyperlight_networking_blocked.json", + description: "HTTP GET to unlisted host is blocked by allowedHosts", + expected_exit: 0, + output_contains: Some("BLOCKED"), + }, ]; let mut failures = Vec::new(); diff --git a/test_configs/hyperlight_networking.json b/test_configs/hyperlight_networking.json new file mode 100644 index 000000000..b500913b2 --- /dev/null +++ b/test_configs/hyperlight_networking.json @@ -0,0 +1,10 @@ +{ + "process": { + "commandLine": "import urllib.request; r = urllib.request.urlopen('http://example.com/'); print(r.status)", + "timeout": 30000 + }, + "containment": "hyperlight", + "network": { + "allowedHosts": ["example.com"] + } +} diff --git a/test_configs/hyperlight_networking_blocked.json b/test_configs/hyperlight_networking_blocked.json new file mode 100644 index 000000000..6a1c23331 --- /dev/null +++ b/test_configs/hyperlight_networking_blocked.json @@ -0,0 +1,10 @@ +{ + "process": { + "commandLine": "import urllib.request\ntry:\n urllib.request.urlopen('http://httpbin.org/', timeout=5)\n print('FAIL: request should have been blocked')\nexcept Exception as e:\n print('BLOCKED:', type(e).__name__)", + "timeout": 30000 + }, + "containment": "hyperlight", + "network": { + "allowedHosts": ["example.com"] + } +}