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_up() 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_down() checks accept policy on output, drop on input, the bypass
log prefix, and the range rejects — present for DOWN, absent for DISENGAGED
(which must also carry no deny-set reject);
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, @t40_project_allow │ │ │
│ │ │ gateway/loopback ports: literal IPs │ │ │
│ │ │ reject: @t20_security_deny, private │ │ │
│ │ └────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌────────────────────────────────────────┐ │ │
│ │ │ 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):
preamble (lo, established, DNS, infra ports, +localhost grants) → t00 hard-deny (link-local + IMDS) → t10 override → t20 security-deny (@t20_security_deny + RFC1918/RFC4193) → t30 provider allow → t40 project allow → bypass window → terminal reject (log BLOCKED)
Rule ordering rationale: tier order is the authority order — an
accept/reject is a terminal verdict, so the first tier that matches wins.
The security-deny tier (@t20_security_deny_v4/_v6, plus the RFC 1918 /
RFC 4193 private-range rejects) sits above the provider- and project-allow
tiers, so an explicit deny wins over an allow, and a private-range host can be
reached only via the break-glass override tier (@t10_override_v4/_v6) —
not by adding it to an allow set. The hard-deny floor (t00, link-local +
169.254.0.0/16 IMDS) sits above even the override and is absolute. Denied
destinations are rejected and logged with the DENIED prefix and stay denied
across shield up / restarts via the security-deny tier and the policy/live
overlay. The terminal rule is a reject (not a bare drop).
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:
t40_project_allow_v4(type ipv4_addr; flags interval;) — IPv4 allowlistt40_project_allow_v6(type ipv6_addr; flags interval;) — IPv6 allowlist
On the live tier the project-allow sets also carry a timeout flag so
dnsmasq-learned entries auto-expire. Parallel t20_security_deny_v4 /
t20_security_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¶
With setup-installed global hooks and a bundle prepared by
pre_start(), 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 tiers) | Hook exits non-zero → torn down |
shield up / down / quarantine additionally verify the applied ruleset
(verify_up / verify_down / verify_quarantine) and raise if any
invariant is missing.
terok-shield setup installs the global hooks. Use terok-shield run
(or call pre_start() via the Python API) before starting containers to
verify setup and prepare their annotated state bundles. Containers without
Shield annotations are not protected by these hooks.