Skip to content

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:

  1. Speak SMTP directly to 127.0.0.1:25 from any TCP-capable language (bash /dev/tcp/127.0.0.1/25, Python smtplib, …).
  2. 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 requests can 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_MODEopen, 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_FALLBACKstrict, 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 openproxied 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:

  1. _NETWORK_BLOCKLIST_DEFAULTS (built-in floor in sandbox-lib.sh; always enforced) — covers the identity-bound exfil + lateral- movement surface enumerated in "Default blocklist".
  2. Admin baseline NETWORK_BLOCKLIST (set in sandbox-admin.conf; user cannot remove).
  3. User NETWORK_BLOCKLIST extensions (additive only).
  4. 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):

  1. exact host:port
  2. exact host (no port)
  3. CIDR with smaller prefix (e.g. /32 highest)
  4. CIDR with larger prefix (e.g. /0 lowest)
  5. wildcard host pattern (*.example.com)
  6. wildcard *
  7. bare port

Decision rules:

  • The most-specific matching rule wins.
  • Among same-specificity rules, NETWORK_BLOCKLIST wins over NETWORK_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 (so getent / pip / git clone keep working) and gives the netns its own empty loopback (so any host MTA on 127.0.0.1 is structurally unreachable). The blocklist is enforced at pasta's own outbound forwarding boundary via the -T ~N (TCP) and -U ~K (UDP) exclusion flags — no iptables / nft dependency.

agent-sandbox auto-detects pasta at session-start. Probe order:

  1. command -v pasta — distro / Homebrew install. Takes precedence; typically newer than the in-tree pin.
  2. apt install passt (Ubuntu 22.10+, Debian Bookworm+)
  3. dnf install passt (Fedora 36+, RHEL 9+)
  4. brew install passt (Linux Homebrew)
  5. tools/pasta/<arch>/pasta — the static binary shipped with agent-sandbox (x86_64 in v1.1). SHA256-pinned; license + provenance in tools/pasta/<arch>/NOTICE. Refresh with ./tools/pasta/fetch.sh; source-build via PASTA_BUILD_FROM_SOURCE=1 ./tools/pasta/fetch.sh for sites with binary-redistribution policy constraints.
  6. lmod (site-specific) — when the site provides a passt module, SANDBOX_MODULES+=("passt/<version>") puts it on PATH. Fred Hutch SciComp tracks a passt module request at FredHutch/easybuild-life-sciences#578 (eventual upgrade path; until then the shipped binary covers FH).
  7. 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:

  1. 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).
  2. Upgrade to kernel ≥ 5.7 with the SO_BINDTODEVICE relaxation.
  3. Pin NETWORK_FILTER_MODE=open or isolated to 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 under open mode.
  • 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=open or filtered.
  • 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:

  1. 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.
  2. Symlink farm + PATH-prefix. A per-launch tempdir under $TMPDIR (mode 0700) 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 to PATH inside 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. Under proxied mode this also fails because the namespace has no native TCP egress; under filtered the 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 through LC_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):

  • pasta not detected: agent-sandbox ships tools/pasta/<arch>/pasta by 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 run tools/pasta/fetch.sh to refresh. make install lays the shipped binary down at <prefix>/lib/agent-sandbox/tools/pasta/<arch>/pasta automatically; if you installed via make install and the binary is missing there, re-run make install.
  • Custom PATH: the probe uses command -v pasta first; if your shell prunes PATH aggressively, ensure /usr/bin (or wherever your distro ships pasta) is reachable, or rely on the shipped in-tree binary which is path-independent.
  • pasta present but degraded to loopback-only (kernel < 5.7 / unprivileged userns / no CAP_NET_RAW). The fallback warning will quote pasta started but degraded to loopback-only forwarding. See "Helper validation: the forwarding probe" above for the three workarounds; the operationally cleanest is an admin running setcap cap_net_raw+ep on a system-wide pasta binary.

Fallback alternatives, all valid:

  • Accept isolated mode (no network at all): pin NETWORK_FILTER_MODE=isolated — the fallback is silent and intentional, and the identity-hijack threat is still closed.
  • Accept open mode (no isolation): pin NETWORK_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=open on landlock-only hosts.
  • Set NETWORK_FILTER_FALLBACK=open to 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=open for 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=isolated for 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 ordering open < filtered < isolated.
  • NETWORK_FILTER_FALLBACK — user can only request a fallback policy >= the admin pin in the strictness ordering open < 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 admin NETWORK_BLOCKLIST pattern 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.