Skip to content

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 via ipaddress.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 allowlist
  • allow_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.