@@ -168,6 +168,8 @@ pub enum NetworkPolicy {
168168 AllowAll ,
169169 /// Only connections to the listed destinations are permitted.
170170 AllowList ( AllowList ) ,
171+ /// All connections are allowed *except* to the listed destinations.
172+ BlockList ( BlockList ) ,
171173}
172174
173175/// A set of allowed network destinations.
@@ -234,19 +236,91 @@ impl AllowList {
234236 }
235237}
236238
239+ /// A set of blocked network destinations.
240+ ///
241+ /// Like [`AllowList`], stores both literal IPs and hostnames. At check
242+ /// time, hostnames are re-resolved so the policy tracks DNS changes.
243+ #[ derive( Clone , Debug ) ]
244+ pub struct BlockList {
245+ blocked_ips : HashSet < IpAddr > ,
246+ hostnames : Vec < String > ,
247+ }
248+
249+ impl BlockList {
250+ /// Build a blocklist from a mixed set of hostnames and IP literals.
251+ ///
252+ /// Hostnames are verified to be resolvable at construction time
253+ /// (fail-closed). At check time they are re-resolved so CDN/anycast
254+ /// rotation doesn't cause false passes.
255+ pub fn from_hosts ( entries : & [ impl AsRef < str > ] ) -> Result < Self > {
256+ use std:: net:: ToSocketAddrs ;
257+ let mut blocked_ips = HashSet :: new ( ) ;
258+ let mut hostnames = Vec :: new ( ) ;
259+ for entry in entries {
260+ let entry = entry. as_ref ( ) ;
261+ if let Ok ( ip) = entry. parse :: < IpAddr > ( ) {
262+ blocked_ips. insert ( ip) ;
263+ } else {
264+ let addrs = ( entry, 0u16 )
265+ . to_socket_addrs ( )
266+ . map_err ( |e| anyhow ! ( "resolve {:?}: {}" , entry, e) ) ?;
267+ let mut found = false ;
268+ for sa in addrs {
269+ blocked_ips. insert ( sa. ip ( ) ) ;
270+ found = true ;
271+ }
272+ if !found {
273+ return Err ( anyhow ! ( "hostname {:?} resolved to zero addresses" , entry) ) ;
274+ }
275+ hostnames. push ( entry. to_string ( ) ) ;
276+ }
277+ }
278+ Ok ( Self {
279+ blocked_ips,
280+ hostnames,
281+ } )
282+ }
283+
284+ fn is_blocked ( & self , ip : & IpAddr ) -> bool {
285+ if self . blocked_ips . contains ( ip) {
286+ return true ;
287+ }
288+ use std:: net:: ToSocketAddrs ;
289+ for host in & self . hostnames {
290+ if let Ok ( addrs) = ( host. as_str ( ) , 0u16 ) . to_socket_addrs ( ) {
291+ for sa in addrs {
292+ if & sa. ip ( ) == ip {
293+ return true ;
294+ }
295+ }
296+ }
297+ }
298+ false
299+ }
300+ }
301+
237302impl NetworkPolicy {
238303 fn check ( & self , addr : & std:: net:: SocketAddr ) -> Result < ( ) > {
239304 match self {
240305 NetworkPolicy :: AllowAll => Ok ( ( ) ) ,
241306 NetworkPolicy :: AllowList ( al) => {
242- // Allow DNS (port 53) — hostname-based allowlists need
243- // the guest to reach DNS servers for resolution.
244307 if addr. port ( ) == 53 || al. is_allowed ( & addr. ip ( ) ) {
245308 Ok ( ( ) )
246309 } else {
247310 Err ( anyhow ! ( "network policy denies connection to {}" , addr) )
248311 }
249312 }
313+ NetworkPolicy :: BlockList ( bl) => {
314+ // DNS (port 53) is always allowed so the guest can resolve names.
315+ if addr. port ( ) == 53 {
316+ return Ok ( ( ) ) ;
317+ }
318+ if bl. is_blocked ( & addr. ip ( ) ) {
319+ Err ( anyhow ! ( "network policy denies connection to {}" , addr) )
320+ } else {
321+ Ok ( ( ) )
322+ }
323+ }
250324 }
251325 }
252326}
@@ -2468,6 +2542,57 @@ mod tests {
24682542 assert ! ( result. is_err( ) ) ;
24692543 }
24702544
2545+ #[ test]
2546+ fn network_policy_blocklist_permits_unlisted_ip ( ) {
2547+ let bl = BlockList :: from_hosts ( & [ "1.2.3.4" ] ) . unwrap ( ) ;
2548+ let policy = NetworkPolicy :: BlockList ( bl) ;
2549+ let addr: std:: net:: SocketAddr = "5.6.7.8:443" . parse ( ) . unwrap ( ) ;
2550+ assert ! ( policy. check( & addr) . is_ok( ) ) ;
2551+ }
2552+
2553+ #[ test]
2554+ fn network_policy_blocklist_denies_listed_ip ( ) {
2555+ let bl = BlockList :: from_hosts ( & [ "1.2.3.4" ] ) . unwrap ( ) ;
2556+ let policy = NetworkPolicy :: BlockList ( bl) ;
2557+ let addr: std:: net:: SocketAddr = "1.2.3.4:80" . parse ( ) . unwrap ( ) ;
2558+ let err = policy. check ( & addr) . unwrap_err ( ) ;
2559+ assert ! ( err. to_string( ) . contains( "network policy denies" ) , "{err}" ) ;
2560+ }
2561+
2562+ #[ test]
2563+ fn network_policy_blocklist_allows_dns ( ) {
2564+ let bl = BlockList :: from_hosts ( & [ "1.2.3.4" ] ) . unwrap ( ) ;
2565+ let policy = NetworkPolicy :: BlockList ( bl) ;
2566+ let addr: std:: net:: SocketAddr = "1.2.3.4:53" . parse ( ) . unwrap ( ) ;
2567+ assert ! ( policy. check( & addr) . is_ok( ) ) ;
2568+ }
2569+
2570+ #[ test]
2571+ fn blocklist_resolves_hostnames ( ) {
2572+ let bl = BlockList :: from_hosts ( & [ "localhost" ] ) . unwrap ( ) ;
2573+ assert ! (
2574+ bl. is_blocked( & "127.0.0.1" . parse( ) . unwrap( ) ) || bl. is_blocked( & "::1" . parse( ) . unwrap( ) )
2575+ ) ;
2576+ }
2577+
2578+ #[ test]
2579+ fn blocklist_rejects_unresolvable_hostname ( ) {
2580+ let result = BlockList :: from_hosts ( & [ "this.host.definitely.does.not.exist.example" ] ) ;
2581+ assert ! ( result. is_err( ) ) ;
2582+ }
2583+
2584+ #[ test]
2585+ fn net_tools_registered_with_blocklist ( ) {
2586+ let mut tools = ToolRegistry :: new ( ) ;
2587+ let exit_code = Arc :: new ( AtomicI32 :: new ( 0 ) ) ;
2588+ let bl = BlockList :: from_hosts ( & [ "1.2.3.4" ] ) . unwrap ( ) ;
2589+ register_internal_tools ( & mut tools, & exit_code, Some ( & NetworkPolicy :: BlockList ( bl) ) ) ;
2590+ let req = br#"{"name":"net_socket","args":{"family":2,"type":1}}"# ;
2591+ let resp = tools. dispatch ( req) ;
2592+ let s = std:: str:: from_utf8 ( & resp) . unwrap ( ) ;
2593+ assert ! ( s. contains( "\" fd\" " ) , "net_socket should work: {s}" ) ;
2594+ }
2595+
24712596 #[ test]
24722597 fn net_tools_not_registered_without_policy ( ) {
24732598 let mut tools = ToolRegistry :: new ( ) ;
0 commit comments