---
title: Networking
description: Rust SDK - Network API reference
---

See [Networking](/networking/overview) for conceptual overview and [TLS Interception](/networking/tls) for TLS proxy details.

## NetworkPolicy

A list of rules plus two per-direction defaults, evaluated first-match-wins. Build one with [`NetworkPolicy::builder()`](#networkpolicybuilder):

```rust
use microsandbox::NetworkPolicy;

let policy = NetworkPolicy::builder()
    .default_deny()
    .egress(|e| e.tcp().port(443).allow_public().allow_private())
    .rule(|r| r.any().deny().ip("198.51.100.5"))
    .build()?;
```

The default policy denies egress except for an implicit `allow public`, and allows ingress with no rules. See the [defaults rationale](/networking/overview#defaults) for the asymmetry.

| Field | Type | Description |
|-------|------|-------------|
| default_egress | [`Action`](#action) | Action when no egress-applicable rule matches |
| default_ingress | [`Action`](#action) | Action when no ingress-applicable rule matches |
| rules | `Vec<`[`Rule`](#rule)`>` | Ordered list of rules; first match wins |

### Rule order matters

The first matching rule wins, so a broad rule placed before a narrow one swallows it:

```rust
NetworkPolicy::builder()
    .default_deny()
    .egress(|e| e
        .allow().cidr("10.0.0.0/8")     // matches everything in 10.x
        .deny().ip("10.0.0.5"))          // never reached
    .build()?;
```

Put specific rules before general ones.

### Shadow detection

`.build()` walks the rules and warns (via `tracing::warn!`) when a rule is fully covered by an earlier one in the same direction. Only `Ip`, `Cidr`, and `Group` destinations are checked; domain coverage depends on runtime DNS and is skipped. Builds still succeed:

```text
WARN rule #1 (Egress Cidr(10.0.0.5/32) Deny) is shadowed by rule #0 (Egress Cidr(10.0.0.0/8) Allow); to narrow, place the more specific rule first
```

### State accumulation

State setters (`.tcp()`, `.port()`, etc.) inside a closure carry into every rule-adder that follows. State is **not reset** between adders:

```rust
.rule(|r| r.egress()
    .tcp().port(443).allow_public()    // rule 1: egress, TCP, 443, allow Public
    .udp().allow_private())            // rule 2: egress, [TCP, UDP], 443, allow Private
```

Use separate closures for rules that need different state.

---

## NetworkPolicy::builder()

The fluent builder is the primary construction path. String inputs (`.ip(&str)`, `.cidr(&str)`, `.domain(&str)`, `.domain_suffix(&str)`) are stored raw and parsed at `.build()` time, so the chain stays clean. The first parse / validation failure surfaces as [`BuildError`](#builderror).

The closure signature for `.rule()` / `.egress()` / `.ingress()` / `.any()` is `FnOnce(&mut RuleBuilder) -> &mut RuleBuilder`. A chain ending in any rule-adder (`.allow_public()`, `.deny().ip(...)`, etc.) returns the builder reference and satisfies the bound. Multi-statement bodies end with an explicit `r` return.

---

#### any()

```rust
fn any<F>(self, f: F) -> Self
where
    F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder
```

Sugar for [`rule()`](#rule) with direction pre-set to `Any`. Rules committed inside apply in both directions.

---

#### build()

```rust
fn build(self) -> Result<NetworkPolicy, BuildError>
```

Consume the builder and produce a [`NetworkPolicy`](#networkpolicy). Lazy-parses every `.ip()` / `.cidr()` / `.domain()` / `.domain_suffix()` input, validates direction-set and ICMP-egress-only invariants, and emits a `tracing::warn!` for each shadowed rule pair detected.

---

#### default_allow()

```rust
fn default_allow(self) -> Self
```

Set both `default_egress` and `default_ingress` to `Allow`.

---

#### default_deny()

```rust
fn default_deny(self) -> Self
```

Set both `default_egress` and `default_ingress` to `Deny`.

---

#### default_egress()

```rust
fn default_egress(self, action: Action) -> Self
```

Per-direction override for the egress default action.

**Parameters**

| Name | Type | Description |
|------|------|-------------|
| action | [`Action`](#action) | Default action for egress |

---

#### default_ingress()

```rust
fn default_ingress(self, action: Action) -> Self
```

Per-direction override for the ingress default action.

**Parameters**

| Name | Type | Description |
|------|------|-------------|
| action | [`Action`](#action) | Default action for ingress |

---

#### egress()

```rust
fn egress<F>(self, f: F) -> Self
where
    F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder
```

Sugar for [`rule()`](#rule) with direction pre-set to `Egress`.

---

#### ingress()

```rust
fn ingress<F>(self, f: F) -> Self
where
    F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder
```

Sugar for [`rule()`](#rule) with direction pre-set to `Ingress`.

---

#### rule()

```rust
fn rule<F>(self, f: F) -> Self
where
    F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder
```

Open a multi-rule batch closure. Direction must be set inside via `.egress()`, `.ingress()`, or `.any()` before any rule-adder.

---

## RuleBuilder

The closure passed to `.rule(...)` (or any of the direction sugar methods) gives you a `RuleBuilder`. State setters and rule-adders interleave freely. State accumulates eagerly across the closure (see [State accumulation](#state-accumulation)).

### Direction setters

Last-write-wins. ICMP rule-adders are egress-only at build time.

---

#### any()

```rust
fn any(&mut self) -> &mut Self
```

Set direction to `Any` for subsequent rule-adders. Rules committed after this apply in both directions.

---

#### egress()

```rust
fn egress(&mut self) -> &mut Self
```

Set direction to `Egress` for subsequent rule-adders.

---

#### ingress()

```rust
fn ingress(&mut self) -> &mut Self
```

Set direction to `Ingress` for subsequent rule-adders.

---

### Protocol setters

Protocols accumulate as a set; duplicates dedupe.

---

#### icmpv4()

```rust
fn icmpv4(&mut self) -> &mut Self
```

Add `Icmpv4` to the protocols set. Egress-only; an ICMP rule on an `Ingress` or `Any` direction fails build with [`BuildError::IngressDoesNotSupportIcmp`](#builderror).

---

#### icmpv6()

```rust
fn icmpv6(&mut self) -> &mut Self
```

Add `Icmpv6` to the protocols set. Egress-only; same rules as [`icmpv4()`](#icmpv4).

---

#### tcp()

```rust
fn tcp(&mut self) -> &mut Self
```

Add `Tcp` to the protocols set.

---

#### udp()

```rust
fn udp(&mut self) -> &mut Self
```

Add `Udp` to the protocols set.

---

### Port setters

Ports accumulate as a set; duplicates dedupe. Always guest-side (egress destination port / ingress listening port).

---

#### port()

```rust
fn port(&mut self, port: u16) -> &mut Self
```

Add a single port to the ports set.

**Parameters**

| Name | Type | Description |
|------|------|-------------|
| port | `u16` | Port number |

---

#### port_range()

```rust
fn port_range(&mut self, lo: u16, hi: u16) -> &mut Self
```

Add an inclusive port range.

**Parameters**

| Name | Type | Description |
|------|------|-------------|
| lo | `u16` | Lower bound (inclusive) |
| hi | `u16` | Upper bound (inclusive). `lo > hi` records [`BuildError::InvalidPortRange`](#builderror) |

---

#### ports()

```rust
fn ports<I: IntoIterator<Item = u16>>(&mut self, ports: I) -> &mut Self
```

Add multiple single ports. Equivalent to calling [`port()`](#port-setters) once per element.

---

### Group rule-adders

Each adder commits one rule using the current state and the named destination group.

---

#### allow_host()

```rust
fn allow_host(&mut self) -> &mut Self
```

Allow the `Host` group: per-sandbox gateway IPs that back `host.microsandbox.internal`. This is the right shortcut for "let the sandbox reach my host's localhost", not [`allow_loopback()`](#allow_loopback).

---

#### allow_link_local()

```rust
fn allow_link_local(&mut self) -> &mut Self
```

Allow the `LinkLocal` group (`169.254.0.0/16`, `fe80::/10`). Excludes the metadata IP `169.254.169.254`.

---

#### allow_loopback()

```rust
fn allow_loopback(&mut self) -> &mut Self
```

Allow the `Loopback` group (`127.0.0.0/8`, `::1`). The **guest's own** loopback, not the host. To reach a service on the host's localhost, use [`allow_host()`](#allow_host) instead. See the [loopback-vs-host watch-out](/networking/overview#loopback-vs-host-a-common-trap).

---

#### allow_meta()

```rust
fn allow_meta(&mut self) -> &mut Self
```

Allow the `Metadata` group (`169.254.169.254`). **Dangerous on cloud hosts** (exposes IAM credentials).

---

#### allow_multicast()

```rust
fn allow_multicast(&mut self) -> &mut Self
```

Allow the `Multicast` group (`224.0.0.0/4`, `ff00::/8`).

---

#### allow_private()

```rust
fn allow_private(&mut self) -> &mut Self
```

Allow the `Private` group (RFC1918 + ULA + CGN).

---

#### allow_public()

```rust
fn allow_public(&mut self) -> &mut Self
```

Allow the `Public` group (complement of named categories: every IP not in any other group).

---

#### deny_host()

```rust
fn deny_host(&mut self) -> &mut Self
```

Deny the `Host` group.

---

#### deny_link_local()

```rust
fn deny_link_local(&mut self) -> &mut Self
```

Deny the `LinkLocal` group.

---

#### deny_loopback()

```rust
fn deny_loopback(&mut self) -> &mut Self
```

Deny the `Loopback` group.

---

#### deny_meta()

```rust
fn deny_meta(&mut self) -> &mut Self
```

Deny the `Metadata` group.

---

#### deny_multicast()

```rust
fn deny_multicast(&mut self) -> &mut Self
```

Deny the `Multicast` group.

---

#### deny_private()

```rust
fn deny_private(&mut self) -> &mut Self
```

Deny the `Private` group.

---

#### deny_public()

```rust
fn deny_public(&mut self) -> &mut Self
```

Deny the `Public` group.

---

### Bulk-domain rule-adders

Each call adds one rule per name, inheriting the current direction / protocol / port state. Lazy-parse: invalid names surface as [`BuildError::InvalidDomain`](#builderror) from `.build()`.

```rust
NetworkPolicy::builder()
    .default_allow()
    .egress(|e| e
        .deny_domains(["evil.com", "tracker.example"])
        .deny_domain_suffixes([".ads.example", ".doubleclick.net"]))
    .build()?
```

---

#### allow_domain_suffixes()

```rust
fn allow_domain_suffixes<I, S>(&mut self, suffixes: I) -> &mut Self
where I: IntoIterator<Item = S>, S: Into<String>
```

Add one `Destination::DomainSuffix` allow rule per suffix.

---

#### allow_domains()

```rust
fn allow_domains<I, S>(&mut self, names: I) -> &mut Self
where I: IntoIterator<Item = S>, S: Into<String>
```

Add one `Destination::Domain` allow rule per name.

---

#### deny_domain_suffixes()

```rust
fn deny_domain_suffixes<I, S>(&mut self, suffixes: I) -> &mut Self
where I: IntoIterator<Item = S>, S: Into<String>
```

Add one `Destination::DomainSuffix` deny rule per suffix.

---

#### deny_domains()

```rust
fn deny_domains<I, S>(&mut self, names: I) -> &mut Self
where I: IntoIterator<Item = S>, S: Into<String>
```

Add one `Destination::Domain` deny rule per name.

---

### Composite rule-adders

---

#### allow_local()

```rust
fn allow_local(&mut self) -> &mut Self
```

Add three allow rules atomically: `Loopback + LinkLocal + Host`. Each uses the closure's current state. `Metadata` is intentionally not included; opt in via [`allow_meta()`](#allow_meta) separately.

---

#### deny_local()

```rust
fn deny_local(&mut self) -> &mut Self
```

Add three deny rules atomically: `Loopback + LinkLocal + Host`. `Metadata` is intentionally not included.

---

### Explicit-destination rule-adders

`.allow()` / `.deny()` open an [`ExplicitRuleBuilder`](#explicitrulebuilder) that requires a destination call to commit.

```rust
.rule(|r| r.egress().tcp().port(443).allow().domain("api.example.com"))
.rule(|r| r.any().deny().cidr("198.51.100.0/24"))
```

Dropping without a destination call adds no rule (the type is `#[must_use]`).

---

#### allow()

```rust
fn allow(&mut self) -> ExplicitRuleBuilder<'_>
```

Begin an explicit-destination rule with action `Allow`.

---

#### deny()

```rust
fn deny(&mut self) -> ExplicitRuleBuilder<'_>
```

Begin an explicit-destination rule with action `Deny`.

---

## NetworkBuilder

Builder for configuring the sandbox's network stack. Used in `SandboxBuilder::network(|n| n...)`. Errors accumulated by nested builders cascade up; the outermost `SandboxBuilder::build()` surfaces them as `MicrosandboxError::NetworkBuilder(BuildError)`.

---

#### dns()

```rust
fn dns(self, f: impl FnOnce(DnsBuilder) -> DnsBuilder) -> Self
```

Configure DNS interception. See [`DnsBuilder`](#dnsbuilder).

```rust
.network(|n| n
    .dns(|d| d
        .nameservers(["1.1.1.1".parse::<Nameserver>()?])
        .query_timeout_ms(3000)
    )
)
```

---

#### max_connections()

```rust
fn max_connections(self, max: usize) -> Self
```

Limit the maximum number of concurrent network connections from the sandbox.

---

#### ipv4_pool()

```rust
fn ipv4_pool(self, pool: Ipv4Network) -> Self
```

Set the IPv4 pool used to derive per-sandbox `/30` guest subnets. Defaults to `172.16.0.0/12`.

---

#### ipv6_pool()

```rust
fn ipv6_pool(self, pool: Ipv6Network) -> Self
```

Set the IPv6 pool used to derive per-sandbox `/64` guest prefixes. Defaults to `fd42:6d73:62::/48`.

---

#### on_secret_violation()

```rust
fn on_secret_violation(
    self,
    f: impl FnOnce(ViolationActionBuilder) -> ViolationActionBuilder,
) -> Self
```

Set the action taken when a secret placeholder is detected in traffic destined for a host not in the secret's allow list. The builder can configure a blocking action or passthrough hosts:

```rust
.network(|n| n.on_secret_violation(|v| {
    v.block_and_log()
     .passthrough_host("api.anthropic.com")
     .passthrough_host_pattern("*.example.com")
}))
```

Passthrough hosts receive the placeholder unchanged. They do **not** receive real secret values.

---

## ViolationActionBuilder

Builder for secret violation behavior. Used by [`NetworkBuilder::on_secret_violation()`](#on_secret_violation) and `SecretBuilder::on_violation(...)`.

| Method | Description |
|--------|-------------|
| `block()` | Block the request silently |
| `block_and_log()` | Block the request and emit a warning log |
| `block_and_terminate()` | Block the request and terminate the sandbox |
| `passthrough_host(host)` | Allow placeholders to pass through unchanged to an exact host |
| `passthrough_host_pattern(pattern)` | Allow placeholders to pass through unchanged to matching wildcard hosts |
| `passthrough_all_hosts(true)` | Allow placeholders to pass through unchanged to any host |

Passthrough host calls accumulate. When passthrough hosts are configured, non-matching hosts use the default secret violation action.

---

#### policy()

```rust
fn policy(self, policy: NetworkPolicy) -> Self
```

Set the network access policy. Pass a builder-constructed [`NetworkPolicy`](#networkpolicy):

```rust
.network(|n| n.policy(NetworkPolicy::builder().default_deny().build()?))
```

---

#### tls()

```rust
fn tls(self, f: impl FnOnce(TlsBuilder) -> TlsBuilder) -> Self
```

Configure TLS interception. See [`TlsBuilder`](#tlsbuilder).

---

#### trust_host_cas()

```rust
fn trust_host_cas(self, enabled: bool) -> Self
```

Whether to ship the host's trusted root CAs into the guest at boot. Default: `false`. Opt in when egress HTTPS inside the sandbox needs to work behind corporate MITM proxies (Cloudflare Warp Zero Trust, Zscaler, Netskope, etc.). These proxies install a gateway CA on the host that's unknown to the guest's stock Mozilla bundle.

---

## DnsBuilder

Builder for DNS interception settings. Used in `NetworkBuilder::dns(|d| d...)`. Owns rebind protection, nameserver pinning, and the per-query timeout.

---

#### nameservers()

```rust
fn nameservers<I>(self, nameservers: I) -> Self
where
    I: IntoIterator,
    I::Item: Into<Nameserver>
```

Set the upstream nameservers to forward DNS queries to. Replaces any previously-set nameservers. Each element is convertible into `Nameserver` (`SocketAddr`, `IpAddr`, or a parsed string via `"dns.google:53".parse::<Nameserver>()?`).

---

#### query_timeout_ms()

```rust
fn query_timeout_ms(self, ms: u64) -> Self
```

Set the per-DNS-query timeout in milliseconds. Default: `5000`.

---

#### rebind_protection()

```rust
fn rebind_protection(self, enabled: bool) -> Self
```

When enabled, DNS responses that resolve to private IP addresses are blocked. Prevents DNS rebinding attacks. Default: `true`.

---

## TlsBuilder

Builder for TLS interception settings. Used in `NetworkBuilder::tls(|t| t...)`.

---

#### block_quic()

```rust
fn block_quic(self, block: bool) -> Self
```

Block QUIC/HTTP3 on intercepted ports, forcing TCP/TLS fallback. Default: `true`.

---

#### bypass()

```rust
fn bypass(self, pattern: impl Into<String>) -> Self
```

Skip TLS interception for hosts matching this glob (e.g. `"*.internal.corp"`). Use for domains with certificate pinning.

---

#### intercept_ca_cert()

```rust
fn intercept_ca_cert(self, path: impl Into<PathBuf>) -> Self
```

PEM file used as the intercepting CA's certificate. Pair with [`intercept_ca_key()`](#intercept_ca_key) to provide a stable CA across sandbox restarts.

---

#### intercept_ca_key()

```rust
fn intercept_ca_key(self, path: impl Into<PathBuf>) -> Self
```

PEM file used as the intercepting CA's private key.

---

#### intercepted_ports()

```rust
fn intercepted_ports(self, ports: Vec<u16>) -> Self
```

TCP ports where TLS interception is active. Default: `[443]`.

---

#### upstream_ca_cert()

```rust
fn upstream_ca_cert(self, path: impl Into<PathBuf>) -> Self
```

PEM file with extra root CAs the proxy should trust when verifying upstream servers.

---

#### verify_upstream()

```rust
fn verify_upstream(self, verify: bool) -> Self
```

Whether the proxy verifies upstream server certificates. Default: `true`. Set to `false` only for self-signed servers.

---

## Types

### Action

| Value | Wire format | Description |
|-------|-------------|-------------|
| `Allow` | `"allow"` | Permit the traffic |
| `Deny` | `"deny"` | Drop the traffic silently |

### Direction

| Value | Wire format | Description |
|-------|-------------|-------------|
| `Egress` | `"egress"` | Traffic leaving the sandbox |
| `Ingress` | `"ingress"` | Traffic entering the sandbox (via published ports) |
| `Any` | `"any"` | Rule applies in either direction |

### Destination

| Variant | Description |
|---------|-------------|
| `Any` | Match any address |
| `Cidr(IpNetwork)` | Match a CIDR range (e.g. `10.0.0.0/8`); single IPs are stored as `/32` or `/128` |
| `Domain(`[`DomainName`](#domainname)`)` | Match an exact domain (e.g. `example.com`) |
| `DomainSuffix(`[`DomainName`](#domainname)`)` | Match the apex domain and every subdomain (e.g. `example.com` and `api.example.com`) |
| `Group(`[`DestinationGroup`](#destinationgroup)`)` | Match a predefined address group |

### DestinationGroup

| Value | Wire format | Matches |
|-------|-------------|---------|
| `Public` | `"public"` | Complement of the other categories: every address not in any other group |
| `Private` | `"private"` | `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `100.64.0.0/10`, `fc00::/7` |
| `Loopback` | `"loopback"` | `127.0.0.0/8`, `::1` (the **guest's own** loopback, not the host) |
| `LinkLocal` | `"link_local"` | `169.254.0.0/16`, `fe80::/10` (excludes metadata) |
| `Metadata` | `"metadata"` | Cloud metadata endpoints (`169.254.169.254`) |
| `Multicast` | `"multicast"` | `224.0.0.0/4`, `ff00::/8` |
| `Host` | `"host"` | Per-sandbox gateway IPs that back `host.microsandbox.internal` |

### DomainName

A validated DNS name. Construction goes through `str::parse` (or `TryFrom<String>`), which delegates to `hickory_proto::rr::Name` and canonicalizes the input (lowercased ASCII, leading and trailing dots stripped) so rule matching is a byte-wise compare against the DNS cache. Invalid inputs return a `DomainNameError`.

```rust
use microsandbox_network::policy::{Destination, DomainName};

let exact: DomainName = "PyPI.Org.".parse()?;     // -> "pypi.org"
let suffix: DomainName = ".example.com".parse()?;  // -> "example.com"
```

Labels follow the permissive DNS grammar (RFC 2181 §11), so underscore-prefixed names like `_service._tcp.example.com` are accepted.

The builder methods (`.domain(&str)`, `.domain_suffix(&str)`) take strings and parse them lazily at `.build()`; callers don't need to construct `DomainName` directly.

### Protocol

| Value | Wire format |
|-------|-------------|
| `Tcp` | `"tcp"` |
| `Udp` | `"udp"` |
| `Icmpv4` | `"icmpv4"` |
| `Icmpv6` | `"icmpv6"` |

ICMP protocols are egress-only. A rule with direction `Ingress` or `Any` carrying an ICMP protocol fails build with [`BuildError::IngressDoesNotSupportIcmp`](#builderror).

### PortRange

```rust
struct PortRange {
    start: u16,  // inclusive
    end: u16,    // inclusive
}
```

| Method | Description |
|--------|-------------|
| `PortRange::single(port)` | Match a single port |
| `PortRange::range(start, end)` | Match an inclusive range |

### Rule

A single network policy rule.

| Field | Type | Description |
|-------|------|-------------|
| direction | [`Direction`](#direction) | Which evaluator considers this rule |
| destination | [`Destination`](#destination) | Target filter (egress destination / ingress source) |
| protocols | `Vec<`[`Protocol`](#protocol)`>` | Set semantics; empty = any protocol |
| ports | `Vec<`[`PortRange`](#portrange)`>` | Set semantics; empty = any port. Always guest-side (egress destination port / ingress listening port) |
| action | [`Action`](#action) | What to do on match |

Convenience constructors:

| Method | Description |
|--------|-------------|
| `Rule::allow_egress(destination)` | Allow rule, direction `Egress` |
| `Rule::deny_egress(destination)` | Deny rule, direction `Egress` |
| `Rule::allow_ingress(destination)` | Allow rule, direction `Ingress` |
| `Rule::deny_ingress(destination)` | Deny rule, direction `Ingress` |
| `Rule::allow_any(destination)` | Allow rule, direction `Any` |
| `Rule::deny_any(destination)` | Deny rule, direction `Any` |
| `Rule::allow_dns()` | Allow plain DNS (UDP/53 + TCP/53) to the gateway forwarder (`Group::Host`). The standard one-liner for opening DNS under a deny-by-default policy. See [DNS as egress](/networking/dns#dns-as-egress) |

### ExplicitRuleBuilder

Returned by [`RuleBuilder`](#rulebuilder)`::allow()` / `::deny()`. Requires exactly one destination method call to commit the rule. The type is `#[must_use]`; dropping without a call adds no rule.

| Method | Description |
|--------|-------------|
| `.ip(impl Into<String>)` | Commit with `Destination::Cidr` of the IP as `/32` or `/128` |
| `.cidr(impl Into<String>)` | Commit with `Destination::Cidr` |
| `.domain(impl Into<String>)` | Commit with `Destination::Domain` |
| `.domain_suffix(impl Into<String>)` | Commit with `Destination::DomainSuffix` |
| `.group(DestinationGroup)` | Commit with `Destination::Group` |
| `.any()` | Commit with `Destination::Any` |

### BuildError

Errors surfaced by the builders' `.build()` methods. The same enum covers `NetworkPolicy::builder()`, `DnsBuilder`, and `NetworkBuilder` (the network and DNS builders accumulate errors lazily; the first failure surfaces from the outermost `.build()` in the chain).

| Variant | Cause |
|---------|-------|
| `DirectionNotSet { rule_index }` | A rule was committed without `.egress() / .ingress() / .any()` |
| `MissingDestination { rule_index }` | `.allow()` or `.deny()` was called but no destination method followed |
| `InvalidIp { rule_index, raw }` | `.ip(&str)` got an unparseable value |
| `InvalidCidr { rule_index, raw }` | `.cidr(&str)` got an unparseable value |
| `InvalidDomain { rule_index, raw, source }` | `.domain` or `.domain_suffix` got a value that failed `DomainName` parse |
| `InvalidPortRange { rule_index, lo, hi }` | `.port_range(lo, hi)` had `lo > hi` |
| `IngressDoesNotSupportIcmp { rule_index }` | ICMP protocol on a non-egress rule |

Inside `SandboxBuilder::build()`, `BuildError` is wrapped as `MicrosandboxError::NetworkBuilder(BuildError)`.

### ViolationAction

Action taken when a secret placeholder is sent to a disallowed host.

| Value | Description |
|-------|-------------|
| `Block` | Silently drop the request. The guest sees a connection reset. This is the default. |
| `BlockAndLog` | Drop the request and emit a warning log on the host side. |
| `BlockAndTerminate` | Drop the request, log an error, and shut down the entire sandbox. |
| `Passthrough(Vec<HostPattern>)` | Forward matching hosts with the placeholder unchanged. Non-matching hosts use the default secret violation action. |
