From c60b76597f2fd222c2178bb3bc20dbe588ecb26a Mon Sep 17 00:00:00 2001 From: Evgeny Boger Date: Fri, 29 May 2026 15:30:20 +0000 Subject: [PATCH] run: --allow-egress + --allow-lan to open egress per-launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new CLI flags to punch holes through the default `public_only` policy without forcing users to rebuild a full NetworkPolicy: --allow-egress 10.100.1.75 --allow-egress 10.100.1.0/24 # CIDR --allow-egress fd00::1/128 # IPv6 --allow-egress [::1] # bare IP gets a /32 or /128 --allow-lan # opens the entire Private group Repeatable; per-IP and group flags compose. Loopback, link-local, and metadata groups stay denied even under --allow-lan — Private is intentionally disjoint from those. 5 unit tests for the parser (bare v4, v4 CIDR, bare v6, v6 CIDR, rejects garbage). Live e2e: baseline (no flag): 169.254.169.254:80 ECONNREFUSED 10.100.1.75:80 ECONNREFUSED --allow-egress 169.254.169.254 --allow-lan: 169.254.169.254:80 OPEN 10.100.1.75:80 OPEN 192.168.1.1:80 OPEN 127.0.0.1:80 ECONNREFUSED (loopback group not opened) --allow-egress 10.100.1.0/24: 10.100.1.75:80 OPEN 10.100.2.1:80 ECONNREFUSED (out of CIDR) Vendor bump (f230c29) carries the matching NetworkBuilder helpers and the new microsandbox::microsandbox_network re-export so we can name DestinationGroup without a direct path dep. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 1 + crates/agent-vm/Cargo.toml | 1 + crates/agent-vm/src/run.rs | 117 ++++++++++++++++++++++++++++++++++++- vendor/microsandbox | 2 +- 4 files changed, 119 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 56d90b3..ae32240 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,6 +28,7 @@ dependencies = [ "clap", "console", "indicatif", + "ipnetwork", "libc", "microsandbox", "microsandbox-network", diff --git a/crates/agent-vm/Cargo.toml b/crates/agent-vm/Cargo.toml index d4f8d6e..9ccd07d 100644 --- a/crates/agent-vm/Cargo.toml +++ b/crates/agent-vm/Cargo.toml @@ -24,6 +24,7 @@ indicatif = "0.18" console = "0.16" reqwest = { version = "0.13", default-features = false, features = ["rustls", "json"] } libc = "0.2" +ipnetwork = "0.21" # Integration tests that drive the actual secret-substitution layer # end-to-end with the hook's anonymisation logic. Kept out of release diff --git a/crates/agent-vm/src/run.rs b/crates/agent-vm/src/run.rs index 07f8d41..4070fb1 100644 --- a/crates/agent-vm/src/run.rs +++ b/crates/agent-vm/src/run.rs @@ -162,6 +162,35 @@ pub struct Args { #[arg(long = "auto-publish", default_value_t = false)] auto_publish: bool, + /// Punch a hole through the default egress policy for one IP + /// or CIDR. Repeatable. Examples: + /// `--allow-egress 10.100.1.75` (single host) + /// `--allow-egress 10.100.1.0/24` (CIDR) + /// `--allow-egress fd00::1/128` (IPv6) + /// + /// The default policy (`NetworkPolicy::public_only`) only + /// allows DNS and the `Public` destination group, so RFC1918 + /// (10/8, 172.16/12, 192.168/16, 100.64/10), loopback, link- + /// local, and metadata addresses are all denied with + /// ECONNREFUSED. Use this flag to reach a specific dev box on + /// the same LAN as the host. Use `--allow-lan` instead if you + /// want to open the entire Private group at once. + #[arg(long = "allow-egress")] + allow_egress: Vec, + + /// Switch the egress policy from `public_only` to `non_local` + /// — adds the entire `DestinationGroup::Private` (10/8, + /// 172.16/12, 192.168/16, 100.64/10, fc00::/7) to the allow + /// list. Coarser than `--allow-egress `; useful for + /// "trust everything on my LAN". Loopback, link-local, and + /// metadata are still denied. + /// + /// Security note: a compromised in-guest process gets full + /// access to every device on your LAN with this flag. Prefer + /// `--allow-egress ` for production-ish uses. + #[arg(long = "allow-lan", default_value_t = false)] + allow_lan: bool, + /// Override the OCI image reference. Default: /// `ghcr.io/wirenboard/agent-vm-template:latest`. Use a timestamped tag /// (`...:YYYY-MM-DDTHH`) to pin a reproducible image. @@ -475,6 +504,20 @@ pub async fn launch(agent: Agent, args: Args) -> Result { ); } + // Parse --allow-egress entries into IpNetwork values. Same + // up-front-bail rule as --publish: syntax errors fail before + // we boot the sandbox. + let allow_egress_cidrs = + parse_allow_egress(&args.allow_egress).context("parsing --allow-egress")?; + for cidr in &allow_egress_cidrs { + eprintln!("==> Egress policy: allowing {cidr}"); + } + if args.allow_lan { + eprintln!( + "==> Egress policy: --allow-lan enabled (Private RFC1918 + 100.64/10 + fc00::/7 reachable)" + ); + } + // For each provider with a host credential file, register a // SecretValue::File secret keyed on the placeholder string the // guest will send, then register the OAuth refresh endpoint as a @@ -490,7 +533,9 @@ pub async fn launch(agent: Agent, args: Args) -> Result { || creds.openai_token_file.is_some() || creds.gh_token_file.is_some(); let auto_publish = args.auto_publish; - if has_creds || !publish_ports.is_empty() || auto_publish { + let allow_lan = args.allow_lan; + let has_egress_overrides = !allow_egress_cidrs.is_empty() || allow_lan; + if has_creds || !publish_ports.is_empty() || auto_publish || has_egress_overrides { use crate::secrets::*; let anthropic = creds.anthropic_token_file.clone(); let openai = creds.openai_token_file.clone(); @@ -501,6 +546,7 @@ pub async fn launch(agent: Agent, args: Args) -> Result { let self_path = std::env::current_exe().context("std::env::current_exe")?; let state_dir = session.state_dir.clone(); let publish_ports_for_net = publish_ports.clone(); + let allow_egress_for_net = allow_egress_cidrs.clone(); builder = builder.network(move |mut n| { n = n.tls(|t| t); for p in &publish_ports_for_net { @@ -513,6 +559,13 @@ pub async fn launch(agent: Agent, args: Args) -> Result { if auto_publish { n = n.auto_publish(); } + if allow_lan { + use microsandbox::microsandbox_network::policy::DestinationGroup; + n = n.allow_egress_group(DestinationGroup::Private); + } + for cidr in &allow_egress_for_net { + n = n.allow_egress_cidr(*cidr); + } if !has_creds { return n; } @@ -1203,6 +1256,30 @@ fn parse_port(entry: &str, field: &str, s: &str) -> Result { .with_context(|| format!("--publish {entry:?}: {field} {s:?} is not a u16")) } +/// Parse `--allow-egress` entries. Each entry is an IP literal or +/// a CIDR (e.g. `10.100.1.75` or `10.100.1.0/24`). A bare IP is +/// expanded to a /32 (v4) or /128 (v6) CIDR — that matches the +/// shape the policy builder's `Destination::Cidr` expects. +fn parse_allow_egress(raw: &[String]) -> Result> { + let mut out = Vec::with_capacity(raw.len()); + for entry in raw { + // Try CIDR first (foo/N); fall back to bare IP. + let cidr = if entry.contains('/') { + entry + .parse::() + .with_context(|| format!("--allow-egress {entry:?}: not a valid CIDR"))? + } else { + let ip: std::net::IpAddr = entry.parse().with_context(|| { + format!("--allow-egress {entry:?}: not an IP address or CIDR") + })?; + // /32 for v4, /128 for v6 — single-host rule. + ipnetwork::IpNetwork::from(ip) + }; + out.push(cidr); + } + Ok(out) +} + /// Parse the raw `--mount` argv strings into `(host, guest)` pairs. /// `HOST` alone defaults `GUEST` to the same absolute path (mirror). /// `HOST:GUEST` lets you remap. Errors clearly on absolute-path @@ -1680,6 +1757,44 @@ mod tests { assert!(parse_publish_args(&["[::1:8080:80".into()]).is_err()); } + // ── parse_allow_egress ─────────────────────────────────────── + + #[test] + fn parse_allow_egress_accepts_bare_ipv4() { + let r = parse_allow_egress(&["10.100.1.75".into()]).expect("ok"); + assert_eq!(r.len(), 1); + // Bare IPv4 → /32 single-host CIDR. + assert_eq!(r[0].prefix(), 32); + assert_eq!(r[0].network().to_string(), "10.100.1.75"); + } + + #[test] + fn parse_allow_egress_accepts_ipv4_cidr() { + let r = parse_allow_egress(&["10.100.1.0/24".into()]).expect("ok"); + assert_eq!(r.len(), 1); + assert_eq!(r[0].prefix(), 24); + } + + #[test] + fn parse_allow_egress_accepts_ipv6_cidr() { + let r = parse_allow_egress(&["fd00::/64".into()]).expect("ok"); + assert_eq!(r.len(), 1); + assert_eq!(r[0].prefix(), 64); + } + + #[test] + fn parse_allow_egress_accepts_bare_ipv6() { + let r = parse_allow_egress(&["fd00::1".into()]).expect("ok"); + assert_eq!(r[0].prefix(), 128); + } + + #[test] + fn parse_allow_egress_rejects_garbage() { + assert!(parse_allow_egress(&["not-an-ip".into()]).is_err()); + assert!(parse_allow_egress(&["10.0.0.1/99".into()]).is_err()); + assert!(parse_allow_egress(&["".into()]).is_err()); + } + #[test] fn parse_publish_args_rejects_bad_input() { assert!(parse_publish_args(&["80".into()]).is_err()); diff --git a/vendor/microsandbox b/vendor/microsandbox index 29ab515..f230c29 160000 --- a/vendor/microsandbox +++ b/vendor/microsandbox @@ -1 +1 @@ -Subproject commit 29ab515e2bf27261f45a1215e9a6a78a25c69610 +Subproject commit f230c29505a17a3599a65e19a3b01804e25bff14