Security & Design¶
Threat model¶
terok-shield provides egress network filtering for rootless Podman containers running untrusted workloads (AI coding agents).
Attacker model: arbitrary code execution inside the container, no host access. The container boundary (rootless Podman + user namespaces) is assumed intact. terok-shield adds the network layer: even with full container control, the workload cannot reach arbitrary internet hosts.
Host privilege model: no root required. The firewall operates within rootless Podman's user namespace.
Goals:
- Default-deny outbound connectivity
- Allowlist-based access to specific destinations (domains or IPs)
- Block private-range traffic (RFC 1918 + RFC 4193, plus link-local RFC 3927 / RFC 4291) unless explicitly allowlisted (prevent lateral movement)
- Audit log all firewall events
- Fail closed on any hook failure
Non-goals:
- Inbound filtering (containers are not exposed)
- DNS exfiltration through allowed resolvers
- Host kernel / container-escape attacks
- Application-layer inspection
Security boundary: nft/rules.py¶
All nftables rulesets are generated by RulesetBuilder in nft/rules.py.
Import isolation: only stdlib (ipaddress, re, textwrap) +
nft/constants.py (pure literals: table name, network defaults, private
ranges, log prefixes). Enforced by an AST import isolation test and bandit
SAST scan.
Input validation: all values are validated before string interpolation:
safe_ip()— validates and normalizes IPs/CIDRs viaipaddress.ip_address/ip_network(dual-stack); returns canonical string form to ensure reliable state-file comparisons_safe_port()— validates port range, rejects bools_safe_ident()/_safe_timeout()— validate nft set/table names and set-element timeouts
Self-verification: RulesetBuilder.verify_hook() checks post-apply
invariants: managed table present, drop policy, both chains, reject type,
dual-stack allow and deny sets declared, all private ranges (RFC 1918/3927 +
RFC 4193/4291) blocked, terminal deny rule with the BLOCKED log prefix.
verify_bypass() checks accept policy on output, drop on input, the bypass
log prefix, and (unless allow_all) the private-range rules;
verify_quarantine() checks the blackout ruleset. shield up / down /
quarantine run the matching verifier after applying and raise on any error.
Trust boundaries¶
┌─────────────────────────────────────────────────────┐
│ Host │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Container netns │ │
│ │ │ │
│ │ ┌────────────────────────────────────────┐ │ │
│ │ │ nftables rules │ │ │
│ │ │ (applied by OCI hook) │ │ │
│ │ │ │ │ │
│ │ │ policy: DROP │ │ │
│ │ │ allow: DNS, lo, @allow_v4/v6 │ │ │
│ │ │ gateway/loopback ports: literal IPs │ │ │
│ │ │ reject: @deny_v4/v6, private ranges │ │ │
│ │ └────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌────────────────────────────────────────┐ │ │
│ │ │ Workload (untrusted) │ │ │
│ │ │ CAP_NET_ADMIN dropped │ │ │
│ │ │ CAP_NET_RAW dropped │ │ │
│ │ └────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ Host services (loopback only) │
└─────────────────────────────────────────────────────┘
The workload cannot modify nftables rules because CAP_NET_ADMIN and
CAP_NET_RAW are dropped.
Chain evaluation order¶
Hook mode (per-container netns, output chain):
loopback → established → DNS → gateway ports → loopback ports → allow_v4/v6 → deny_v4/v6 → private-range reject → deny all
Rule ordering rationale: the allow sets (@allow_v4, @allow_v6) are
evaluated before private-range reject rules. This lets operators allowlist
specific RFC 1918 or RFC 4193 addresses (e.g., local infrastructure) via
allowlist profiles. The deny sets (@deny_v4, @deny_v6) sit between the
allow sets and the private-range rules; explicitly denied destinations are
rejected and logged with the DENIED prefix, and stay denied across
shield up / restarts via deny.list.
Dual-stack (IPv4 + IPv6)¶
The firewall operates in dual-stack mode using nftables inet tables, which
handle both IPv4 and IPv6 within the same ruleset. Two parallel allow sets
are maintained:
allow_v4(type ipv4_addr; flags interval;) — IPv4 allowlistallow_v6(type ipv6_addr; flags interval;) — IPv6 allowlist
On the dnsmasq tier the allow sets also carry a timeout flag so
dnsmasq-learned entries auto-expire. Parallel deny_v4 / deny_v6 sets
hold explicitly denied destinations.
DNS resolution queries both A and AAAA records, and resolved addresses are automatically routed to the correct set. Rejects are cross-family:
- All private ranges use
reject with icmpx admin-prohibited - Default deny also uses
reject with icmpx admin-prohibited
Fail-closed guarantees¶
After pre_start() installs hooks, this
invariant holds: no path from "firewall setup failed" to "container running
unrestricted."
| Failure | Result |
|---|---|
| OCI hook raises an exception | Container torn down by podman (non-zero exit) |
nft binary missing or fails |
Hook exits non-zero → torn down |
state_dir annotation missing or invalid |
Hook exits non-zero → torn down |
| Bundle version mismatch | Hook exits non-zero → torn down |
ruleset.nft missing |
Hook exits non-zero → torn down |
| Ruleset fails to load | Hook exits non-zero → torn down |
| dnsmasq fails to start (dnsmasq tier) | Hook exits non-zero → torn down |
shield up / down / quarantine additionally verify the applied ruleset
(verify_hook / verify_bypass / verify_quarantine) and raise if any
invariant is missing.
The fail-closed guarantee applies once hooks are installed by pre_start().
Use terok-shield run (or call pre_start() via the Python API) before
starting containers — without hooks, containers start without firewall rules
and no egress filtering is applied.