Chio/Docs
LOGIN · JOIN

Network Guards

Keep agents on the domains you list and off internal addresses, with two guards that check each outbound host before dispatch.

Concept chio@5f63e6e

On this page

An agent with a network tool reaches the domains you list and nothing on your internal network. Two guards check the host of each network call routed through the kernel: internal-network refuses private, loopback, link-local, and cloud-metadata targets, and egress-allowlist passes only hosts that match an allow pattern.

Both read the host from ToolAction::NetworkEgress(host, _), so they evaluate a call once action classification has named it a network call; every other action passes them with Verdict::Allow. Under standard kernel wiring both already run. EgressAllowlistGuard is part of GuardPipeline::default_pipeline(), and InternalNetworkGuard is a pre-invocation guard in default_runtime_guard_profile().

The allowlist reads a host first, and internal-network checks what it admits

A compiled policy runs the two guards as one rail: egress-allowlist, then internal-network. The first check that refuses a host decides, and nothing after it runs.

Network guards, in check orderChoose an input.
r = api.openai.comrblockg1allowg2defaultg3metadatag4extrahostsg5rebindingg6privateIPg7encodedIPg8vallowrapi.openai.comegress-allowlistg1blockg2allowg3defaultinternal-networkg4metadatag5extra hostsg6rebindingg7private IPg8encoded IPvallow

A listed domain passes both guards

The default block list is empty and the default allow pattern *.openai.com admits the name. No internal-network check matches it, so both guards allow. Default allowlist.

Two guards drawn as one rail in the order a compiled policy runs them, egress-allowlist then internal-network; the first refusal decides. The rebinding and bare-domain cases add the allow pattern *.example.com to the defaults. DNS resolution at connect time happens later, in the node's egress contract, and is left out.

The allowlist reads the host first. A block pattern refuses it, an allow pattern admits it, and a host that matches neither falls to the default refusal. Only an admitted host reaches the internal-network checks, which run in a fixed order: cloud-metadata names, your extra blocked hosts, the rebinding heuristic, then the address itself, parsed as an IP or tested as an encoded literal. Under the defaults, 169.254.169.254 stops at the allowlist's default. Add a wildcard such as *.example.com and it admits 127-0-0-1.example.com, which the rebinding check then refuses: internal-network is the backstop for whatever an allow pattern lets through.

Each refusal is a guard deny: the kernel refuses the call before dispatch and signs a deny receipt. Its reason names internal-network for an address refusal. For an allowlist refusal it names the guard-pipeline wrapper, and the receipt's evidence entry names egress-allowlist.

A pipeline you assemble uses the same order

A host that embeds only these two guards, such as a WASM or embedded host, registers them itself. Every guard must allow, so either order refuses the same calls; the order decides which guard a refusal names. Register EgressAllowlistGuard first, as a compiled policy does, so receipts from embedded and compiled hosts name the same guard for the same host.

src/guards.rs · embedded hostrust
use chio_guards::{EgressAllowlistGuard, InternalNetworkGuard};

let mut pipeline = chio_guards::GuardPipeline::new();
pipeline.add(Box::new(EgressAllowlistGuard::new()));   // domain allowlist
pipeline.add(Box::new(InternalNetworkGuard::new()));   // SSRF companion

// Install on the existing mutable kernel.
kernel.add_guard(Box::new(pipeline));

The pair covers a trusted name turned hostile. An attacker who controls an allowlisted hostname through DNS poisoning or a dangling subdomain can point it at 169.254.169.254; when the agent submits that literal, no allow pattern covers it and the allowlist refuses it, and an allowlist permissive enough to admit it still hands it to internal-network. A name that resolves to a private address at connect time is the node's to catch.

Read more in Default Pipelines, which lists both default sets.

A host must match an allow pattern, and a block pattern overrides it

EgressAllowlistGuard lowercases the host and compares it with glob::Pattern. Block patterns are read first and refuse on the first match. Allow patterns pass on the first match, and a host that matches neither is refused. The default allows nine patterns covering the OpenAI, Anthropic, and GitHub APIs and the npm, PyPI, and crates.io registries, with an empty block list. In a policy, the HushSpec egress rule sets both lists:

policy.yamlyaml
hushspec: "0.1.0"
rules:
  egress:
    enabled: true
    allow:
      - "*.openai.com"
      - "api.anthropic.com"
      - "pricing.larkfield.example"
    block:
      - "*.example.net"

Glob matching is literal. *.larkfield.example matches pricing.larkfield.example and leaves the bare larkfield.example to the default deny, so list the bare domain as its own pattern when you need it.

Pin the three outcomes this policy must keep in a case table: an allowed API host, a host the block list refuses, and the metadata address that internal-network refuses whatever the lists say. A case with code fails unless that guard decides it:

egress.test.yamlyaml
policy: ./policy.yaml
server: web
cases:
  - name: model API on the allow list
    tool: fetch
    params: {url: "https://api.anthropic.com/v1/messages"}
    expect: allow
  - name: bootstrap host on the block list
    tool: fetch
    params: {url: "https://pkgs.example.net/bootstrap.sh"}
    expect: deny
    code: egress-allowlist
  - name: cloud metadata address
    tool: fetch
    params: {url: "http://169.254.169.254/latest/meta-data/"}
    expect: deny
    code: internal-network
Terminalbash
chio policy test egress.test.yaml
Output
policy   ./policy.yaml
PASS  model API on the allow list        allow
PASS  bootstrap host on the block list   deny   guard egress-allowlist
PASS  cloud metadata address             deny   guard internal-network
3 cases: 3 passed

Address classes, metadata names, and disguised literals are refused by name

InternalNetworkGuard refuses IPv4 loopback, the three RFC 1918 private ranges, link-local, broadcast, and the zero network; IPv6 loopback, link-local, unique-local, and unspecified addresses; and IPv4-mapped IPv6 forms of private addresses. It also refuses the cloud-metadata endpoints by address and by name, the Kubernetes API service names, and any host ending in .internal.

The .internal match is a suffix test on the lowercased host. If your tenancy serves legitimate public services under .internal, override this guard.

Two heuristics catch addresses written to slip past a parser. Encoded-IP detection refuses hexadecimal (0x7f000001), decimal (2130706433), and octal-dotted (0177.0.0.1) spellings. With dns_rebinding_detection on, its default, a name that carries a private-address substring such as 127-0-0-1 or 192-168- is refused, so evil.127-0-0-1.attacker.com stops. Turn the substring scan off with with_config(_, false) when it refuses legitimate names.

Read more in the blocked address classes below, with each range and its source.

The node checks resolved addresses at connect time

Both guards read the host string and perform no DNS lookup. An attacker-controlled name that resolves to a private address at TCP-connect time reaches the connection step with its name intact.

The node's egress contract checks that step: it resolves the host and re-checks every returned address on the same resolution the connection uses. Outside the node, pair the guards with an egress proxy that re-validates after DNS, or a host firewall that enforces the same private ranges. Keep resolved-address checks in the connection path whether or not the rebinding heuristic is on.

Read more in Node egress.

Network guard reference

EgressAllowlistGuard

Source: crates/guards/chio-guards/src/egress_allowlist.rs. Guard name: egress-allowlist. Domain matching uses glob::Pattern against the lowercased host string.

Struct

crates/guards/chio-guards/src/egress_allowlist.rsrust
pub struct EgressAllowlistGuard {
    allow_patterns: Vec<Pattern>,
    block_patterns: Vec<Pattern>,
}

// ...

impl EgressAllowlistGuard {
    pub fn new() -> Self;
    // ...
    pub fn with_lists(
        allow: Vec<String>,
        block: Vec<String>,
    ) -> Result<Self, EgressAllowlistConfigError>;
    // ...
    pub fn is_allowed(&self, domain: &str) -> bool;
}

Default allowlist

Built by default_allow_patterns(). That is the entire default; anything else is denied. The default block list is empty.

crates/guards/chio-guards/src/egress_allowlist.rs33-47rust
fn default_allow_patterns() -> Vec<String> {
    vec![
        // Common AI/ML APIs
        "*.openai.com".to_string(),
        "*.anthropic.com".to_string(),
        "api.github.com".to_string(),
        // Package registries
        "*.npmjs.org".to_string(),
        "registry.npmjs.org".to_string(),
        "pypi.org".to_string(),
        "files.pythonhosted.org".to_string(),
        "crates.io".to_string(),
        "static.crates.io".to_string(),
    ]
}

Configuration

KnobTypeDefaultPurpose
allow_patterns
Vec<String>
9 globs
Allowed hosts. A host must match at least one.
block_patterns
Vec<String>
empty
Hard blocks that override the allowlist. Checked first.

Algorithm

  1. Lowercase the input host.
  2. For each block pattern, deny on the first match. The block list takes precedence over the allow list.
  3. For each allow pattern, allow on the first match.
  4. Fall through to deny.

Failure modes

  • An invalid allow glob returns EgressAllowlistConfigError::InvalidAllowPattern.
  • An invalid block glob returns EgressAllowlistConfigError::InvalidBlockPattern.
  • Non-network actions return Verdict::Allow.
  • EgressAllowlistGuard::new() keeps running if the default config fails to compile. Through build_default_or_fail_closed() it falls back to an empty-allowlist guard (no allow patterns, no block patterns), which denies all egress.

InternalNetworkGuard

SSRF defense. Source: crates/guards/chio-guards/src/internal_network.rs. Guard name: internal-network. Runs in addition to EgressAllowlistGuard.

Struct

crates/guards/chio-guards/src/internal_network.rsrust
pub struct InternalNetworkGuard {
    extra_blocked_hosts: Vec<String>,
    dns_rebinding_detection: bool,
}

impl InternalNetworkGuard {
    pub fn new() -> Self;
    // ...
    pub fn with_config(extra_blocked_hosts: Vec<String>, dns_rebinding_detection: bool) -> Self;
    // ...
    pub fn check_host(&self, host: &str) -> Option<String>;
}

Configuration

KnobTypeDefaultPurpose
extra_blocked_hosts
Vec<String>
empty
Additional exact-match hostnames to block (case-insensitive).
dns_rebinding_detection
bool
true
When on, hostnames containing IP-octet substrings flag.

Blocked address classes

From is_private_ip and is_cloud_metadata_host.

ClassRange or valueSource
IPv4 loopback
127.0.0.0/8
RFC 1122
IPv4 private (Class A)
10.0.0.0/8
RFC 1918
IPv4 private (Class B)
172.16.0.0/12
RFC 1918
IPv4 private (Class C)
192.168.0.0/16
RFC 1918
IPv4 link-local
169.254.0.0/16
RFC 3927
IPv4 broadcast
255.255.255.255
RFC 919
IPv4 zero network
0.0.0.0/8
RFC 791
IPv6 loopback
::1
RFC 4291
IPv6 link-local
fe80::/10
RFC 4291
IPv6 unique-local
fc00::/7
RFC 4193
IPv6 unspecified
::
RFC 4291
IPv4-mapped IPv6
::ffff:<ipv4> when ipv4 is private
RFC 4291
Cloud metadata IP
169.254.169.254
AWS / GCP / Azure
GCP metadata host
metadata.google.internal
GCP
Azure metadata host
metadata.azure.com
Azure
EC2 instance-data
instance-data
AWS
Internal TLD
any host ending in .internal
conventional
Kubernetes API
kubernetes.default.svc, kubernetes.default
Kubernetes

Any host whose name ends with .internal is blocked unconditionally, by a suffix match on the lowercased host.

Encoded-IP detection

From looks_like_encoded_ip, which catches hostnames that obfuscate a private-IP literal.

EncodingExampleHeuristic
Hexadecimal
0x7f000001
Starts with 0x, all hex digits.
Decimal
2130706433 (= 127.0.0.1)
7 to 10 ASCII digits.
Octal-dotted
0177.0.0.1
Starts with 0, contains a dot, and one component starts with 0 and is > 1 char.

DNS-rebinding heuristics

From is_dns_rebinding_suspect. When a hostname is not itself a parseable IP but contains a substring that looks like a private IP, the guard flags it. The matched substrings:

crates/guards/chio-guards/src/internal_network.rsrust
let suspicious_patterns = [
    "127-0-0-1",
    "127.0.0.1",
    "10-0-",
    "10.0.",
    "192-168-",
    "192.168.",
    "172-16-",
    "172.16.",
    "169-254-",
    "169.254.",
    "0x7f",   // hex-encoded 127
    "0177.",  // octal 127
];

evil.127-0-0-1.attacker.com and evil.192-168-1.attacker.com flag. with_config(_, false) disables this layer; resolved-address checks belong in the connection path either way.

Algorithm

  1. Lowercase the host. Match cloud-metadata names first (the literal table plus the .internal suffix).
  2. Compare against extra_blocked_hosts (case-insensitive equality).
  3. If dns_rebinding_detection is on and the host is not itself a parseable IP, run the suspicious-substring scan.
  4. Try to parse the host as an IpAddr. If it parses, run is_private_ip. If it does not, run looks_like_encoded_ip.
  5. The first match wins. Otherwise allow.

Failure modes

  • Non-network actions return Verdict::Allow.
  • The guard does no DNS lookups, so a hostname that resolves to a private IP at TCP-connect time passes it. The node's egress contract resolves the host and re-checks every returned address on the same resolution the connect uses; elsewhere, use an egress proxy that re-validates after DNS or a host firewall with the same private-range policy.

Next: confirm your tool calls are classified as network calls