Network filter¶
The network filter is an optional layer that restricts the sandboxed agent's outbound network access. It exists to close one specific threat: a compromised agent abusing the host's network to spoof mail under the operator's identity (and adjacent lateral-movement / exfiltration surface).
Threat model¶
On a typical HPC compute node (or any shared multi-tenant host) the
local MTA — Mail Transfer Agent, the daemon (e.g. Postfix or
sendmail) that accepts mail for delivery — listens on 127.0.0.1:25
and accepts the standard local mail-submission protocol (ports
25/465/587/2525) from any local user without authentication, then
relays under that user's From: header. Postfix's mynetworks
directive frequently extends that trust to a wider organizational
CIDR (lab subnet, campus /16, etc.), so an adversary controlling a
sandboxed agent can spoof the operator's identity in two ways:
- Speak SMTP directly to
127.0.0.1:25from any TCP-capable language (bash /dev/tcp/127.0.0.1/25, Pythonsmtplib, …). - Speak SMTP directly to a nearby relay whose IP is trusted by
mynetworks— the relay accepts unauthenticated submission for any sender, again under the operator's identity.
Both bypass any filesystem-level binary block. The fix is to deny the TCP path itself at a layer the agent cannot escape.
Non-goals. This layer does NOT defend against:
- Covert L7 channels over allowed 443 hostnames the policy admits (a
determined agent with
pip install requestscan POST to any HTTPS endpoint the policy permits). The default blocklist closes the obvious universal exfil channels (webhooks-as-mail, paste sites, transactional-email HTTPS APIs); SNI-level filtering of arbitrary HTTPS destinations is a future layer. - Host-side mail policy bypass via mechanisms outside the sandbox's control (a privileged user on the host with mail-spool access). Negotiate host-side mail policy with your site's operations team as the Layer 3 complement.
Modes¶
The four values of NETWORK_FILTER_MODE — open, filtered, proxied (new in 0.10.1), isolated — and the mechanism each selects are defined on the configure page. Strictness ordering: open < filtered < proxied < isolated. This section covers the behavioural detail: what filtered actually enforces at runtime, what it skips, and how proxied mediates the netns-no-outbound chokepoint.
Default state (v1.1). When pasta is available on the host,
NETWORK_FILTER_MODE=filtered delivers port-level outbound
enforcement: pasta provisions a netns with a tap interface
forwarding to the host network and a private (empty) loopback, and
the bwrap workload runs inside that netns. The port-level blocklist
is enforced at pasta's own outbound boundary via -T ~N (TCP) and
-U ~K (UDP) exclusion flags generated from
effective_network_blocklist. No nftables / iptables dependency.
NETWORK_FILTER_MODE |
What happens (v0.10.1) |
|---|---|
open |
shares host network (legacy behaviour; layer disabled) |
filtered |
bwrap inside a pasta netns with -T/-U port exclusions enforcing the universal port floor (SMTP submission 24/25/465/587/2525, DoT 853, telnet/finger/rsh/rexec/rsyslog) plus any operator-added bare-port or universal-CIDR-port entries. Falls back per NETWORK_FILTER_FALLBACK when pasta is unavailable. |
proxied |
bwrap with --unshare-net + bind-mounted Unix sockets to a host-side HTTP CONNECT + SOCKS5 daemon (tools/proxy/agent-sandbox-proxy.py); blocklist enforced at CONNECT time PLUS a hardened IP floor (RFC1918, loopback, link-local, cloud metadata). Bwrap only; firejail/landlock unsupported. |
isolated |
full network kill via bwrap --unshare-net / firejail --net=none |
agent-sandbox ships a verified static pasta binary at
tools/pasta/<arch>/pasta (x86_64 in v1.1); on Linux hosts this is
the only runtime requirement for filtered mode. See "Helper
sourcing" below for the full probe order and alternative install
paths.
Upgrade from v1.0 (enforcement flip). v1.0 shipped the
configuration surface + fallback machinery and gated the
helper-probe behind NETWORK_FILTER_ENABLE_HELPER_PROBE=1 — its
default filtered + stricter fell back silently to isolated,
so the layer was inert in practice. v1.1 ungates the probe.
Deployments running the v1.0 defaults will START enforcing real
filtered mode the moment v1.1 lands on a host with pasta
available (and agent-sandbox ships pasta in-tree, so the
"available" condition is almost always met).
If your CI / test harness depended on the v1.0 silent-isolated
fallback (e.g., needed an outbound port the default blocklist
closes), either add NETWORK_BLOCKLIST_EXCEPT+=(<port>) for the
specific port or pin NETWORK_FILTER_MODE=open for those runs.
Enforcement scope — what pasta -T/-U covers, and what it
doesn't. pasta's port-exclusion syntax filters by destination
port at the netns boundary. It does NOT inspect destination
hostnames or CIDRs at this layer (that's L4-and-up, requiring SNI
inspection or a transparent proxy).
What v1.1 enforces:
- Universal bare-port closures: 25, 465, 587, 2525 (SMTP
submission class), 853 (DoT), 23/79/113/512/513/514
(telnet/finger/ident/rexec/rlogin/rsh).
- Loopback host:port entries: 127.0.0.1:25 etc. — already
structurally unreachable because pasta gives the netns its own
empty loopback, and the universal port closure double-covers.
- Universal 0.0.0.0/0:N entries — same port-level outcome.
- Bare-port NETWORK_BLOCKLIST_EXCEPT carve-outs lift the
corresponding port closure.
What v1.1 does NOT enforce (skipped silently; emit notes only when
NETWORK_FILTER_VERBOSE=1):
- Hostname entries (api.mailgun.net, hooks.slack.com, etc.) —
port-level layer can't resolve hostnames-to-IPs at runtime, and
even if it did the IPs rotate.
- Wildcard hostnames (*.cloudflare-dns.com) — needs SNI
inspection.
- The * deny-all pattern — would break DNS resolution through
pasta's proxy; operators wanting deny-all should pin
NETWORK_FILTER_MODE=isolated directly.
- Site CIDR with non-universal port (e.g. 10.0.0.0/8:443) —
enforced as universal-port closure (port-only); the
CIDR-specificity is dropped.
The identity-hijack threat that motivated this feature (local-MTA abuse via SMTP submission) is fully closed by the universal port-class closure. The hostname-level entries in the default blocklist are tracked for v1.2 L7-proxy work (SNI-aware filtering; R3 in survey, deferred — see settylab/dotto-nexus#117).
Fallback policies¶
The three values of NETWORK_FILTER_FALLBACK — strict, stricter, open — and the strictness ordering (open < stricter < strict) live on the configure page. The interesting content here is which backend can deliver which mode and what each fallback policy actually produces per requested-mode × backend-support combination.
Per-backend support, v0.10.1:
| Backend | open |
filtered |
proxied |
isolated |
|---|---|---|---|---|
| bwrap | ✓ | ✓ when pasta is available (shipped in-tree at tools/pasta/<arch>/pasta, or via distro passt package); otherwise falls back per policy |
✓ when python3 is on PATH (the bundled tools/proxy/agent-sandbox-proxy.py is the helper) |
✓ (--unshare-net) |
| firejail | ✓ | ✗ (needs a site-provisioned bridge via --net=<iface> + --netfilter; v0.10.1 does not auto-provision the bridge — use bwrap or accept the fallback) |
✗ (bwrap-only in v0.10.1; firejail parity tracked for follow-up) | ✓ (--net=none) |
| landlock | ✓ | ✗ (no mount/network namespace) | ✗ (no namespace primitives) | ✗ (no network namespace) |
Fallback decision matrix¶
stricter walks the strictness chain LEAST-strict-step-up first
(smallest weakening) so a degraded-pasta host lands on proxied
before isolated. open walks the LESS-strict chain MOST-strict-
first; proxied is stricter than filtered, so the open-policy
default-config user on a degraded-pasta host still lands on open
— proxied is opt-in via MODE=proxied or FALLBACK=stricter.
| Requested | Backend supports it? | Policy strict |
Policy stricter |
Policy open |
|---|---|---|---|---|
filtered |
yes | filtered | filtered | filtered |
filtered |
no (bwrap; degraded pasta; proxied supported) | FAIL | proxied (loud warning; v0.10.1 default fallback target) | open (loud warning) |
filtered |
no (bwrap; degraded pasta; proxied unsupported) | FAIL | isolated (loud warning) | open (loud warning) |
filtered |
no (landlock; no netns) | FAIL | FAIL (no stricter mode possible) | open (loud warning) |
proxied |
yes (bwrap; python3 available) | proxied | proxied | proxied |
proxied |
no (firejail/landlock) | FAIL | isolated (loud warning, firejail) | filtered/open (loud warning, less-strict) |
isolated |
yes | isolated | isolated | isolated |
isolated |
no (landlock; no netns) | FAIL | FAIL (no stricter mode possible) | open (loud warning; only available less-strict mode on landlock) |
isolated |
no (bwrap, helper present) | FAIL | FAIL | proxied/filtered (loud warning; most-strict less-strict option) |
open |
(always) | open | open | open |
Note for the open policy row: open never falls to a stricter
mode than requested. If you want filtered but want to accept
proxied (then isolated) as a fallback when no helper is
available, use stricter (not open).
Configuration¶
The user-facing knobs — NETWORK_FILTER_MODE, NETWORK_FILTER_FALLBACK, NETWORK_BLOCKLIST, NETWORK_BLOCKLIST_EXCEPT, and the NETWORK_FILTER_SKIP_HELPER_PROBE env override — are documented per-knob in the configure page's Network filter section. This section covers the syntax of blocklist patterns and the runtime apply order; the precedence model and worked examples follow below.
Pattern syntax for NETWORK_BLOCKLIST and NETWORK_BLOCKLIST_EXCEPT¶
| Pattern | Meaning |
|---|---|
"host" |
block all ports on this hostname/IP |
"host:port" |
block specific port |
"CIDR" |
block all ports on this CIDR range |
"CIDR:port" |
block specific port on this range |
"port" (numeric) |
block this port outbound on every destination |
"[ipv6]:port" |
IPv6 form |
"*.suffix" |
bash-glob wildcard on the host part (matches any subdomain prefix) |
"*" |
matches every destination (deny-all base for the implicit-allowlist idiom) |
The runtime applies the union of:
_NETWORK_BLOCKLIST_DEFAULTS(built-in floor insandbox-lib.sh; always enforced) — covers the identity-bound exfil + lateral- movement surface enumerated in "Default blocklist".- Admin baseline
NETWORK_BLOCKLIST(set insandbox-admin.conf; user cannot remove). - User
NETWORK_BLOCKLISTextensions (additive only). - Exception list
NETWORK_BLOCKLIST_EXCEPT(admin + user merged; user entries covered by admin BLOCKLIST are stripped at config- load — see "Precedence model" below).
Inspect the effective lists at runtime:
# From a test harness or sandbox-aware tool
source sandbox-lib.sh
effective_network_blocklist # block entries (floor + admin + user)
effective_network_exception_list # allowed exceptions (admin + user, post-strip)
Precedence model¶
Policy resolution under v1.1 enforcement (bwrap + pasta + nft):
Specificity (most → least specific):
- exact
host:port - exact
host(no port) - CIDR with smaller prefix (e.g.
/32highest) - CIDR with larger prefix (e.g.
/0lowest) - wildcard host pattern (
*.example.com) - wildcard
* - bare
port
Decision rules:
- The most-specific matching rule wins.
- Among same-specificity rules,
NETWORK_BLOCKLISTwins overNETWORK_BLOCKLIST_EXCEPT(safer default). - Admin-set rules win over user-set rules at every specificity level.
Worked examples:
| Blocklist | Except list | Connection | Outcome |
|---|---|---|---|
*.example.com |
— | api.example.com |
block (wildcard match) |
*.example.com |
api.example.com |
api.example.com |
allow (exception more specific) |
*.example.com |
api.example.com |
foo.example.com |
block (no matching exception) |
*.amazonaws.com |
s3.amazonaws.com |
s3.amazonaws.com |
allow |
* |
github.com, api.openai.com |
github.com |
allow (implicit-allowlist idiom) |
* |
github.com, api.openai.com |
pastebin.com |
block (no exception) |
Admin precedence¶
A NETWORK_BLOCKLIST entry set in sandbox-admin.conf cannot be
carved out by a user NETWORK_BLOCKLIST_EXCEPT. The check runs at
config-load time under bash-glob semantics:
# admin sandbox-admin.conf
NETWORK_BLOCKLIST+=("*.example.com")
# user sandbox.conf
NETWORK_BLOCKLIST_EXCEPT+=("api.example.com") # stripped at load!
The user's api.example.com exception is removed and a warning is
emitted:
WARNING: User config attempted to except 'api.example.com' but
admin NETWORK_BLOCKLIST has '*.example.com' which covers it —
exception stripped (admin policy is absolute).
Admins can carve their own exceptions in their own
NETWORK_BLOCKLIST_EXCEPT (admin policy is the floor for both
arrays).
Implicit-allowlist idiom (* + exact hosts)¶
For deployments that want deny-by-default semantics, the canonical pattern is:
NETWORK_BLOCKLIST+=("*")
NETWORK_BLOCKLIST_EXCEPT+=(
"github.com" "api.github.com"
"api.anthropic.com" "api.openai.com"
"pypi.org" "files.pythonhosted.org"
"conda.anaconda.org"
# … your minimal essential set
)
The * rule has the lowest specificity, so any exact-host exception
overrides it. Future-deferred: a curated default-allowlist preset
(survey reference R3) would package this pattern with sensible
defaults; the user direction in
settylab/dotto-nexus#117
deferred R3 in favour of the blocklist-not-allowlist model — the
idiom above remains available for power users who want the inverse
shape.
Resolver pinning — is it needed?¶
Empirically verified on a representative HPC node (gizmok87,
Ubuntu 18.04, kernel 5.4):
$ stat -c '%a %F %N' /etc/resolv.conf
777 symbolic link '/etc/resolv.conf' -> '../run/systemd/resolve/stub-resolv.conf'
$ ls -lL /etc/resolv.conf
-rw-r--r-- 1 nobody 732 May 12 14:54 /etc/resolv.conf
$ [[ -w /etc/resolv.conf ]] && echo writable || echo RO
RO
/etc/resolv.conf is RO to unprivileged users on the host, and
inside the sandbox /etc is bind-mounted read-only via
READONLY_MOUNTS. The same is true for /etc/hosts (644 root-owned)
and /etc/nsswitch.conf (660 root-owned). No resolver-rewrite
step is needed; the existing read-only bind-mount of /etc
already pins these files inside the sandbox.
The resolver-evasion surface that actually matters for the threat
model is application-level: Python dnspython, Go's
net.Resolver, Rust's hickory-dns, and similar libraries can open
their own TCP/UDP sockets to a DoH/DoT endpoint, bypassing
/etc/resolv.conf entirely. The network-filter floor blocks this
class:
- DoH hostnames (
cloudflare-dns.com,dns.google,dns.quad9.net,mozilla.cloudflare-dns.com) — closes the HTTPS-tunnelled lookup. - DoT port 853 (universal port block) — closes the TLS-wrapped lookup.
Other resolver-mutation surfaces, evaluated:
| Surface | Matters? | Why |
|---|---|---|
/etc/resolv.conf rewrite |
no | RO via READONLY_MOUNTS on /etc |
/etc/hosts mutation |
no | same RO bind-mount |
/etc/nsswitch.conf re-order |
no | same RO bind-mount |
LD_PRELOAD intercepting getaddrinfo |
no | the attacker isn't using the system resolver; they'd skip the libc path entirely |
RES_OPTIONS env var |
no | glibc-resolver only; attacker routes around |
| Application DoH/DoT clients | yes | covered by the DoH-hostname + DoT-port block in the floor |
So the practical defense is the network-layer block of the DoH hostnames + DoT port (already in the floor), not an in-sandbox resolver pin.
Helper sourcing (pasta — no nft)¶
filtered mode on bwrap needs only one helper:
pasta— userspace TCP/IP stack from the passt project (BSD-3-Clause arm of the dual license). Provisions a tap interface inside the sandbox's netns and forwards general outbound traffic to the host's network. Also proxies DNS to the host resolver by default (sogetent/pip/git clonekeep working) and gives the netns its own empty loopback (so any host MTA on127.0.0.1is structurally unreachable). The blocklist is enforced at pasta's own outbound forwarding boundary via the-T ~N(TCP) and-U ~K(UDP) exclusion flags — noiptables/nftdependency.
agent-sandbox auto-detects pasta at session-start. Probe order:
command -v pasta— distro / Homebrew install. Takes precedence; typically newer than the in-tree pin.apt install passt(Ubuntu 22.10+, Debian Bookworm+)dnf install passt(Fedora 36+, RHEL 9+)brew install passt(Linux Homebrew)tools/pasta/<arch>/pasta— the static binary shipped with agent-sandbox (x86_64 in v1.1). SHA256-pinned; license + provenance intools/pasta/<arch>/NOTICE. Refresh with./tools/pasta/fetch.sh; source-build viaPASTA_BUILD_FROM_SOURCE=1 ./tools/pasta/fetch.shfor sites with binary-redistribution policy constraints.- lmod (site-specific) — when the site provides a
passtmodule,SANDBOX_MODULES+=("passt/<version>")puts it on PATH. Fred Hutch SciComp tracks apasstmodule request at FredHutch/easybuild-life-sciences#578 (eventual upgrade path; until then the shipped binary covers FH). command -v slirp4netns— older, slower fallback. v1.1 reserves slirp4netns support and currently downgrades to isolated mode with a warning when only slirp4netns is present.
If pasta is missing or its forwarding probe trips, the resolver
falls back per NETWORK_FILTER_FALLBACK (default open; loud
warning naming the gap). Sites that need the stronger default-deny
posture should pin stricter (or strict) in their admin baseline.
Helper validation: the forwarding probe¶
Pasta-binary presence is necessary but not sufficient. On kernels
that still gate SO_BINDTODEVICE behind CAP_NET_RAW (most kernels
< 5.7, or any host without the 2020 relaxation backported), an
unprivileged pasta starts but logs
SO_BINDTODEVICE unavailable, forwarding only 127.0.0.1 and ::1 for '-T auto'
SO_BINDTODEVICE unavailable, forwarding only 127.0.0.1 and ::1 for '-U auto'
and silently restricts forwarding to loopback. The sandbox would launch with the documented pasta argv, and the agent would lose outbound on every port — including ports the blocklist did not exclude. Both the threat-model intent (close mail-relay / webhook / paste / DoH / legacy-r-services) and the operator's reach expectation collapse.
To close that gap, _pasta_can_forward_outbound runs
pasta --foreground --quiet -- true after resolving the binary,
inspects stderr for the forwarding only 127.0.0.1 banner, and on
match flips _NETWORK_HELPER_PROBE_RESULT="degraded". The resolver
treats degraded helpers identically to missing helpers — filtered
is not in the supported-modes set; fallback proceeds per
NETWORK_FILTER_FALLBACK. The fallback warning quotes the specific
degradation reason rather than the generic "pasta not found" line so
the operator knows the path forward:
setcap cap_net_raw+ep <pasta>on a system-wide pasta binary (admin/root needed; cleanest fix; survives upgrades until the pasta package version changes).- Upgrade to kernel ≥ 5.7 with the SO_BINDTODEVICE relaxation.
- Pin
NETWORK_FILTER_MODE=openorisolatedto make the reach trade-off explicit.
Probe escape hatch — NETWORK_FILTER_SKIP_HELPER_PROBE=1¶
Operators who have verified their pasta's host-side forwarding works
(e.g. ran the setcap step above) can set
NETWORK_FILTER_SKIP_HELPER_PROBE=1 to skip the ~50ms probe per
sandbox start. Do not set it as a workaround for the degradation
warning — setting it on a host where pasta actually degrades
re-introduces the silent-loopback-only failure mode that the probe
exists to catch.
Proxied mode (host-side HTTP CONNECT + SOCKS5 fallback)¶
NETWORK_FILTER_MODE=proxied (v0.10.1+) is the chokepoint mode:
the sandbox runs inside an empty network namespace, and every outbound
connection must pass through a host-side policy proxy. The proxy
listens on two Unix sockets in a per-launch dir (mode 0700, under
$XDG_RUNTIME_DIR when available, else $TMPDIR); bwrap bind-mounts
the dir read-only at the same path on both sides of the sandbox
boundary (mirroring the chaperon FIFO pattern), so the in-sandbox
bridge opens the same socket paths the host-side server bound. An
in-sandbox bridge (also Python; runs as PID 1 inside the netns)
listens on 127.0.0.1:44889 (HTTP) and 127.0.0.1:44890 (SOCKS5) and
forwards bytes byte-for-byte to the bind-mounted Unix sockets.
HTTP_PROXY, HTTPS_PROXY, http_proxy, https_proxy, ALL_PROXY,
and NO_PROXY are pre-set inside the sandbox so the standard suite
of tools (curl, pip, conda, git, gh, Claude SDK, etc.) routes through
the proxy without further configuration.
Why this mode exists¶
When pasta cannot deliver filtered (typically: kernel < 5.7
without setcap cap_net_raw+ep on the pasta binary, common on shared
HPC login nodes), the pre-v0.10.1 fallback chain offered two
choices: isolated (no DNS / pip / git — sandbox effectively
unusable) or open (host network — loses port-level enforcement).
proxied is the third path: the sandbox is just as isolated at the
namespace boundary as isolated, but proxy-aware tools still work.
Set NETWORK_FILTER_FALLBACK=stricter to land on it automatically;
or set NETWORK_FILTER_MODE=proxied to opt in unconditionally.
What's enforced¶
| Layer | Check |
|---|---|
| Host-string normalisation | reject CR/LF/NUL/space/@/#/?///\; reject decimal-int / hex / octal IPv4 quirks (2130706433, 0x7f000001); IDN-encode unicode and lowercase. |
| DNS-rebind defence | resolve hostname ONCE, check the resolved IP against the floor + blocklist, connect to that literal IP. No re-resolve between policy decision and connect. |
| Hardened IP floor | 127.0.0.0/8, 169.254.0.0/16 (cloud metadata + link-local IPv4), 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 100.64.0.0/10, 0.0.0.0/8, ::1/128, fe80::/10, fc00::/7 (includes fd00:ec2::254 AWS IPv6 metadata), ::/8. Always denied; not lifted by NETWORK_BLOCKLIST_EXCEPT. |
NETWORK_BLOCKLIST |
full enforcement of exact-host, wildcard hostname (*.suffix), CIDR, and bare-port entries — at the proxy CONNECT boundary, not at L4 (so wildcard / hostname entries are now load-bearing under proxied, unlike filtered where the L4 layer skipped them). |
NETWORK_BLOCKLIST_EXCEPT |
same precedence model as elsewhere; an EXCEPT entry carves through a BLOCK entry it covers. Does NOT lift the hardened IP floor. |
What breaks under proxied¶
The trade-off for the chokepoint is loss of every protocol the proxy does not speak. Inside the sandbox:
ssh host(direct): blocked. Workaround:ssh -o ProxyCommand='nc -X 5 -x 127.0.0.1:44890 %h %p'routes ssh through the SOCKS5 proxy.dig,nslookup,host,getent hosts: blocked (no resolver inside the netns). Name resolution happens host-side inside the proxy. Debug DNS on the host, not in the sandbox.ping/ ICMP: blocked (HTTP CONNECT and SOCKS5 do not forward ICMP).bash /dev/tcp/host/port: blocked. The empty netns has no native TCP path out. This is the intentional hardening side-effect — the same primitive was an exfil surface underopenmode.- Spark / MPI / NCCL / arbitrary TCP daemons: blocked. The proxy is for proxy-aware clients only. Workloads needing arbitrary TCP egress must pin
NETWORK_FILTER_MODE=openorfiltered. - UDP (except DNS-over-HTTPS via the proxy): not supported.
Loopback inside the sandbox (e.g. an agent-spawned Jupyter kernel
on 127.0.0.1:N) is reachable as usual: NO_PROXY includes
127.0.0.1, localhost, ::1, and [::1]. The bridge listener
addresses themselves (127.0.0.1:44889/44890) are in NO_PROXY so
proxy-aware clients don't recursively proxy through themselves.
Resource cost¶
Each sandbox launches one host-side proxy daemon (~25 MB RSS) and one
in-sandbox bridge (also ~25 MB). At 20 parallel agents on a shared
node, total ~1 GB across all daemons. Acceptable on typical HPC
compute nodes; tight on cgroup-memory-capped login nodes (often 8-16
GB per user) — pin NETWORK_FILTER_MODE=open for sessions that
need every byte.
Lifecycle¶
sandbox-exec.sh spawns the host-side proxy daemon BEFORE bwrap; the
daemon arms prctl(PR_SET_PDEATHSIG, SIGTERM) as its first action so
it dies cleanly when the parent shell exits. The cleanup trap
kills the daemon and rm -rf's the per-launch socket dir as
belt-and-suspenders for the pre-exec failure window.
Outbound mail policy¶
NETWORK_MAIL_BLOCK (v0.10.1+) is the upstream layer above the
port-level SMTP block. While NETWORK_FILTER_MODE closes
TCP ports 25 / 465 / 587 (and the local-MTA loopback variants) at the
namespace edge, the mail-block layer replaces every canonical mailer
binary inside the sandbox with a stub that prints a deterrent
message and exits 77 (sysexits EX_NOPERM). The stub catches the
execve syscall, so the agent learns the policy in human-readable
terms before the kernel drops a connection.
Why two layers¶
The port-level filter catches every dialer that reaches the SMTP
socket — python -c 'import smtplib...', curl smtp://relay:25,
nc <host> 25. But an agent that exec's sendmail gets a connection
refused / ENETUNREACH after a 30-second timeout, which reads as a
transient network fault and invites retry. The stub closes the
common case (every UNIX tool that respects the sendmail interface,
plus every canonical mailer name on PATH) at the syscall, with a
message text that explicitly forecloses the search tree:
agent-sandbox: outbound mail is disabled in this configuration.
This is a configured boundary, not a transient fault. Every known
mailer on this system — sendmail, mail, mailx, mutt, msmtp, ssmtp,
s-nail, swaks, the postfix admin tools, exim, dma, qmail — has been
shimmed by this stub. Retrying with another binary, another
invocation, or another path will produce the same result. The
companion network filter additionally blocks SMTP ports at the
namespace edge, so application-level dialers (smtplib, curl smtp://,
nc) also fail.
Agents: do not retry. This rule is enforced, not advisory; persistence
escalates the incident. ...
Two reinforcing layers, evaluated CONFIG > NETWORK. The agent reads the policy in plain text; the kernel still enforces it on the wire.
┌──────────────────────────────┐
│ AGENT exec's `sendmail -t` │
└────────────┬─────────────────┘
│
CONFIG layer │ stub layer (tools/mail-block/)
────────── │
argv[0] resolves │ → /usr/sbin/sendmail is bind-
to the bound mounted to mail-block-stub.sh
path │ → per-launch stubs dir under
│ $TMPDIR (bound same-path on both
│ sides) is PATH-prefixed and
│ shadows host-PATH lookups
│ → stub prints deterrent message,
│ exits 77 (EX_NOPERM)
│
▼ if the agent escalates to a
language-level dialer instead…
NETWORK layer
─────────────
python -c 'smtplib...' ↘
curl smtp://relay:25 ─→ NETWORK_FILTER_MODE
nc relay 25 ↗ blocks 25/465/587 at
the namespace edge
Configuration¶
| Value | Behaviour |
|---|---|
auto (default) |
on whenever the configured NETWORK_FILTER_MODE OR the resolved one is anything other than open (strictest-of-both rule). Disengages only when BOTH are open. |
on |
always on, regardless of NETWORK_FILTER_MODE. |
off |
never on. Escape hatch for sites that legitimately need the canonical mailer binaries visible. |
The strictest-of-both rule matters when NETWORK_FILTER_FALLBACK=open
degrades a filtered/proxied/isolated request to open (e.g. on a
kernel without setcap cap_net_raw+ep on pasta). The user's configured
intent — "constrain outbound network" — is still in force; the fallback
policy only authorises degrading the network layer, not withdrawing all
egress concerns. Mail-block doesn't depend on the kernel features the
network filter gated on, so it can still fire — and this is precisely
when defense-in-depth earns its name. Symmetrically, under
NETWORK_FILTER_FALLBACK=stricter a configured open can be walked up
to filtered (or higher); the stricter realised state is what an
outside observer would see, so the mail-block layer follows.
Admin enforcement: harden-only. A user-configured value can be equal
to or stricter than the admin pin (off < auto < on); attempts to
weaken raise a WARNING at config-load and restore the admin value.
See NETWORK_MAIL_BLOCK for
the full operator-facing description.
Canonical mailer name set¶
The stub is invoked under each of the following names. The list is
maintained as _MAIL_BLOCK_STUB_NAMES in sandbox-lib.sh:
| Family | Names |
|---|---|
| Sendmail interface | sendmail, sendmail.sendmail, sendmail.postfix, rmail |
| mail / mailx | mail, mailx, Mail, s-nail, nail, bsd-mailx, heirloom-mailx |
| mutt | mutt, neomutt |
| SMTP-direct | msmtp, ssmtp, nullmailer-send, smtp-cli |
| Postfix admin | postsuper, postdrop, postqueue, mailq, newaliases |
| Test tool | swaks |
| mpack | mpack, metasend |
| Exim admin | exim, exim4 |
| DragonFly Mail Agent | dma |
| qmail client | qmail-inject, qmail-qmqpc, qmail-remote |
git send-email and git imap-send invoke an external sendmail
for delivery, so the sendmail stub covers them.
Mechanism¶
Two reinforcing path-resolution layers, both established at
backend_prepare time:
- Bind-mount over canonical absolute paths. For each name, the
launcher tries
/usr/bin/<name>,/usr/sbin/<name>,/usr/lib/sendmail(Debian historical),/var/qmail/bin/qmail-*(qmail convention). Entries that don't exist on the host are silently skipped — there's no need to materialise phantom paths because layer 2 covers PATH-resolution misses. - Symlink farm + PATH-prefix. A per-launch tempdir under
$TMPDIR(mode0700) is populated with one symlink per name pointing at the in-sandbox stub path. The dir is--ro-bind'd at the same path on both sides of the sandbox boundary (mirroring the chaperon FIFO + proxy-socket-dir pattern) and prepended toPATHinside the sandbox. Catches PATH lookups that resolve to/usr/local/bin/<name>, Lmod-injected/app/software/<pkg>/bin/<name>, or any other host-PATH-position layer 1 missed.
Layer 2 fires FIRST in PATH order (before $SANDBOX_DIR/chaperon/stubs,
$SANDBOX_DIR/bin, and the rest of $PATH) so the stub wins even
when host PATH contains an unstubbed copy.
What it catches and what it doesn't¶
Catches (the easy ~80%):
- Every UNIX tool that respects the sendmail interface (
git send-email, alpine, mh-mail, …). - Every name that resolves through PATH lookup, regardless of where the resolved path lives (PATH-prefix layer wins over Lmod-injected, brew-installed, hand-built copies).
Does NOT catch (the network filter does — see Modes):
python -c 'import smtplib; …'— language-level dialer.curl smtp://relay:25 ...— application-level dialer.nc <host> 25— raw TCP. Underproxiedmode this also fails because the namespace has no native TCP egress; underfilteredthe port closure does the work.
Argv echo sanitization¶
A naive stub that echoes its argv invites a control-byte injection
surface — a crafted argv[1] containing \e[2J\e[H rewrites the
agent's terminal view, OSC-8 sequences smuggle clickable URLs into
log scrapers. The stub instead:
- Reports only
basename "$0"(so the reader sees which mailer the agent reached for), passed throughLC_ALL=C tr -cd '[:graph:]'to strip every byte outside printable ASCII, capped at 64 bytes. - Reports the argv count (so the agent can distinguish a probe from a real send attempt), never the contents.
Exit code is 77 (EX_NOPERM from sysexits.h, "permission denied
at a higher level"). EX_CONFIG (78) was considered and rejected:
it reads as "operator misconfiguration, retry with a fix" which
invites exactly the retry loop the stub is meant to break.
Backend support¶
Bwrap only in v0.10.1. Firejail and landlock parity is mechanically straightforward but deferred so the initial release stays auditable — the layer falls back to no-stub on unsupported backends; the network filter still applies per the backend matrix.
Operator opt-out¶
NETWORK_MAIL_BLOCK=off is the escape hatch. Rare in practice: HPC
sites typically route legitimate mail through a host-side relay or
a separate non-sandboxed task, not through a sandboxed mailer
binary. If you find yourself reaching for it, prefer MODE=open —
which carries the entire mail policy with it — and document the
reason inline in ~/.config/agent-sandbox/sandbox.conf so a future
operator (or auditor) can find it.
Real-world recipe — verify filtered mode is enforcing¶
After deploying v1.1, an operator can confirm filtered mode is
actually enforcing inside their sandbox with a handful of one-liners.
Run each inside a sandbox session:
# (1) DNS + general egress: should resolve + reach github.com.
getent hosts github.com && \
curl -fsS --max-time 5 -o /dev/null -w '%{http_code}\n' https://github.com/
# Expected: A/AAAA record + "200" or "301".
# (2) SMTP submission: must fail. The universal port-25 closure plus
# pasta's empty loopback both block the path.
exec 3<>/dev/tcp/127.0.0.1/25 2>&1 || echo "BLOCKED — SMTP closed (expected)"
# Expected: "BLOCKED" (Connection refused / ENETUNREACH).
# (3) DoT (DNS-over-TLS) evasion port: must fail. Universal port-853
# closure.
exec 3<>/dev/tcp/1.1.1.1/853 2>&1 || echo "BLOCKED — DoT closed (expected)"
# Expected: "BLOCKED".
# (4) Telnet (legacy r-services): must fail. Port 23 closure.
exec 3<>/dev/tcp/127.0.0.1/23 2>&1 || echo "BLOCKED — telnet closed (expected)"
# Expected: "BLOCKED".
Note that v1.1 enforces port-level blocks at pasta's boundary,
not hostname-level blocks. A request like
curl https://hooks.slack.com/ (a hostname entry in the default
blocklist) will not fail in v1.1 — hostname-level filtering is
v1.2 L7-proxy scope. Plan defense-in-depth accordingly: the
universal port-class closure shuts the identity-hijack threat (the
motivating concern); hostname surfaces are best handled at the
egress proxy or DNS layer.
If (1) fails: pasta is not on PATH and the in-tree binary is
missing or not executable. Re-run with NETWORK_FILTER_VERBOSE=1
to surface the helper-probe trail.
If (2)/(3)/(4) succeed: filtered did not resolve. Check the
startup output for the fallback-warning (most likely filtered →
isolated because pasta is missing, or filtered → open under a
NETWORK_FILTER_FALLBACK=open policy).
Troubleshooting¶
"filtered fell back to isolated" on startup (v1.1)¶
filtered requires pasta AND a working SO_BINDTODEVICE. Common
causes (the fallback warning quotes the specific reason):
pastanot detected: agent-sandbox shipstools/pasta/<arch>/pastaby default. If you removed it (or are on an unsupported arch like aarch64 in v1.1), install via distro package (apt install passt/dnf install passt/brew install passt) or runtools/pasta/fetch.shto refresh.make installlays the shipped binary down at<prefix>/lib/agent-sandbox/tools/pasta/<arch>/pastaautomatically; if you installed viamake installand the binary is missing there, re-runmake install.- Custom
PATH: the probe usescommand -v pastafirst; if your shell prunesPATHaggressively, ensure/usr/bin(or wherever your distro shipspasta) is reachable, or rely on the shipped in-tree binary which is path-independent. pastapresent but degraded to loopback-only (kernel < 5.7 / unprivileged userns / noCAP_NET_RAW). The fallback warning will quotepasta started but degraded to loopback-only forwarding. See "Helper validation: the forwarding probe" above for the three workarounds; the operationally cleanest is an admin runningsetcap cap_net_raw+epon a system-wide pasta binary.
Fallback alternatives, all valid:
- Accept
isolatedmode (no network at all): pinNETWORK_FILTER_MODE=isolated— the fallback is silent and intentional, and the identity-hijack threat is still closed. - Accept
openmode (no isolation): pinNETWORK_FILTER_MODE=open. Re-opens the threat. Use only when host-side mail policy is already locked down at the MTA layer.
"no stricter mode available" failure on landlock¶
Landlock has neither a mount namespace nor a network namespace, so it
cannot deliver filtered or isolated. Choices:
- Switch to a bwrap or firejail backend (
SANDBOX_BACKEND=bwrap). - Pin
NETWORK_FILTER_MODE=openon landlock-only hosts. - Set
NETWORK_FILTER_FALLBACK=opento accept the silent degrade.
sandbox-notify carve-out¶
bin/sandbox-notify uses /dev/tty + tmux IPC (tmux new-window)
and does NOT speak SMTP or any other network protocol. It continues
to function in all three modes including isolated. No carve-out
required at the configuration level.
Pre-existing tests fail with "ENETUNREACH" or per-port blocks¶
In v1.1, when pasta is present on the runner,
NETWORK_FILTER_MODE=filtered (default) enforces the universal
port floor — ports 24/25/465/587/2525 (SMTP submission class), 853
(DoT), 23/79/113/512/513/514 (legacy r-services). CI / local test
runs that need ports the default blocklist closes can either:
- Pin
NETWORK_FILTER_MODE=openfor the duration of the test (often the right call for CI runners on isolated infrastructure). - Add
NETWORK_BLOCKLIST_EXCEPT+=(<port>)for the specific bare port the test legitimately needs (host:port exceptions are not carved at the pasta layer — port-level enforcement is universal). - Pin
NETWORK_FILTER_MODE=isolatedfor tests that explicitly exercise the no-network path.
The test suite already gates its network-dependent sections on the
resolved mode (see test.sh section 11.4 "Network filter").
Admin enforcement (sandbox-admin.conf)¶
An admin baseline pins values that user config cannot weaken. Per-knob admin-enforcement semantics are documented on the configure page (each knob's Admin-enforced field); the consolidated view for the network-filter knobs:
NETWORK_FILTER_MODE— user can only request a mode>=the admin pin in the strictness orderingopen<filtered<isolated.NETWORK_FILTER_FALLBACK— user can only request a fallback policy>=the admin pin in the strictness orderingopen<stricter<strict.NETWORK_BLOCKLIST— admin entries become a floor; the user's effective list must be a superset (entries the admin set cannot be removed).NETWORK_BLOCKLIST_EXCEPT— user exceptions covered by an adminNETWORK_BLOCKLISTpattern are stripped at config-load (admin policy is absolute; see Admin precedence above).
Violations are restored at config-load time with a WARNING naming the offending entry. See NETWORK_FILTER_MODE and adjacent entries on the configure page for the knob-level reference.
See also¶
- Security model — overall sandbox threat model and layering.
- Hardening — admin's view of the defense-in-depth stack.
passt.top— upstream documentation for pasta.