Network Guards
Keep agents on the domains you list and off internal addresses, with two guards that check each outbound host before dispatch.
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.
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.
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.
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:
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:
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-networkchio policy test egress.test.yamlpolicy ./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
Try dns_rebinding_detection, .internal, or InvalidAllowPattern.
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
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.
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
| Knob | Type | Default | Purpose |
|---|---|---|---|
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
- Lowercase the input host.
- For each block pattern, deny on the first match. The block list takes precedence over the allow list.
- For each allow pattern, allow on the first match.
- 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. Throughbuild_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
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
| Knob | Type | Default | Purpose |
|---|---|---|---|
extra_ | Vec<String> | empty | Additional exact-match hostnames to block (case- |
dns_ | bool | true | When on, hostnames containing IP-octet substrings flag. |
Blocked address classes
From is_private_ip and is_cloud_metadata_host.
| Class | Range or value | Source |
|---|---|---|
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. | GCP |
Azure metadata host | metadata. | Azure |
EC2 instance-data | instance-data | AWS |
Internal TLD | any host ending in .internal | conventional |
Kubernetes API | kubernetes., kubernetes. | 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.
| Encoding | Example | Heuristic |
|---|---|---|
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:
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
- Lowercase the host. Match cloud-metadata names first (the literal table plus the
.internalsuffix). - Compare against
extra_blocked_hosts(case-insensitive equality). - If
dns_rebinding_detectionis on and the host is not itself a parseable IP, run the suspicious-substring scan. - Try to parse the host as an
IpAddr. If it parses, runis_private_ip. If it does not, runlooks_like_encoded_ip. - 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.