Network Guard
Activated by --no-network (CLI) or block_network=True (API).
Implemented in hermetic/guards/network.py.
What it patches
| Surface | What happens |
|---|---|
socket.socket |
Replaced with GuardedSocket, a subclass that vetoes outbound connect, connect_ex, sendto, sendmsg, and non-loopback bind. |
socket.SocketType |
Aliased to GuardedSocket for libraries that import the type alias. |
socket.create_connection |
Wrapped — checks the host before dialing. |
socket.getaddrinfo |
Wrapped — DNS for a non-allowlisted host raises. |
socket.gethostbyname, gethostbyname_ex |
Wrapped — same. |
socket.socketpair, socket.fromfd, socket.fromshare |
Hard-blocked (defense against fd-resurrection bypasses). |
ssl.SSLContext.wrap_socket |
Hard-blocked. |
Allow-listing
Two knobs control what is permitted through the guard:
--allow-localhost / allow_localhost=True
Permits connections to 127.0.0.1, ::1, localhost, and 0.0.0.0.
Without this flag, even loopback is denied.
--allow-domain DOMAIN / allow_domains=["DOMAIN"]
Permits connections to a specific host. Matching is suffix-based:
--allow-domain example.comallowsexample.comand*.example.com.- It does not allow
example.com.attacker.example(attacker-controlled suffix) — the match is anchored to the dot boundary. - It does not allow
notexample.com(no shared suffix).
Multiple domains: pass --allow-domain repeatedly, or pass a list to
the API.
Always denied: cloud metadata
These hosts are denied even if you allow-list them:
169.254.169.254(AWS / Azure / OpenStack / DigitalOcean)metadata.google.internal,metadata(GCP)fd00:ec2::254,fd00:ec2:0:0:0:0:0:254(AWS IMDSv2 IPv6)fe80::a9fe:a9fe(link-local SLAAC variant)100.100.100.200(Alibaba Cloud)
The motivation is credential exfiltration in cloud CI: hostile code that finds a way past the network guard for a normal host should not be able to grab IAM credentials from the metadata service.
What it does not catch
A few honest limitations, kept here so you can decide whether to pair hermetic with a stronger sandbox:
_socket.socketdirect construction. The C-level base class is not patched — if it were,socket.socket.__init__would recurse infinitely. Code that doesimport _socket; _socket.socket(...)gets a raw socket. The patchedgetaddrinfo/create_connectionsurface still applies, so the attacker also has to do their own DNS, which is blocked.- Captured class references. A library that did
from socket import socketbefore hermetic installed has the original class in its module dict. If it instantiates that, it bypassesGuardedSocket. Most stdlib (and most well-known third-party) modules look upsocket.socketlazily, but not all. - DoH (DNS-over-HTTPS) through an allow-listed domain. If you allow-list a CDN that fronts a public DoH resolver, an attacker can resolve any host through it. Out of scope.
See Threat Model for the full enumeration.
Tracing
With --trace, blocked calls write one line each to stderr:
[hermetic] blocked socket.connect host=example.com reason=no-network
[hermetic] blocked socket.getaddrinfo host=example.com reason=no-network
[hermetic] blocked socket.bind host=0.0.0.0 reason=no-network
Hosts are not redacted — they're the addresses the calling code
chose, and the user already knows them. If your allow-list contains
secrets, don't enable --trace.
Examples
Block everything, including localhost:
hermetic --no-network -- python -c "import urllib.request; urllib.request.urlopen('https://example.com')"
Block everything except localhost (typical test setup):
hermetic --no-network --allow-localhost -- pytest tests/
Allow one external API:
hermetic --no-network --allow-domain api.openai.com -- python my_agent.py
Allow a private CIDR via its DNS name (you cannot allow-list raw IP ranges; use a hostname):
hermetic --no-network --allow-domain internal.company.local -- my_app