Skip to content

state

state

Per-container state bundle layout contract.

Every shielded container gets an isolated state directory. This module is the single source of truth for where files live within it — all paths are derived from a single state_dir root through StateBundle.

Bundle layout::

{state_dir}/
├── policy/                        # v15 tiered +/- policy (one file per tier set)
│   ├── 10-override                #   → t10_override
│   ├── 20-security-deny           #   → t20_security_deny
│   ├── 30-provider-allow          #   → t30_provider_allow
│   ├── 40-project-allow           #   → t40_project_allow
│   └── live                       #   runtime overlay (folded into its tiers)
├── resolved.ips                   # derived: resolved allow IPs (t40 set seed)
├── override_resolved.ips          # derived: resolved t10 override IPs (above-deny seed)
├── deny_resolved.ips              # derived: resolved t20 security-deny IPs (deny seed)
├── ruleset.nft                    # pre-generated nft ruleset (gateways baked in)
├── upstream.dns                   # upstream DNS address
├── dns.tier                       # active DNS tier
├── dnsmasq.command                # symbolic/operator launch choice
├── dnsmasq.bin                    # live executable identity for cleanup
├── network.mode                   # rootless network mode (pasta/slirp4netns)
├── loopback.ports                 # per-container host-loopback TCP ports (newline-separated)
├── dnsmasq.conf                   # generated dnsmasq configuration
├── dnsmasq.pid                    # dnsmasq PID (in container netns)
├── dnsmasq.log                    # dnsmasq query log (for shield watch)
├── resolv.conf                    # bind-mounted over /etc/resolv.conf on every tier
├── container.id                   # podman container ID (short, 12-char hex)
└── audit.jsonl                    # per-container audit log

BUNDLE_VERSION = 18 module-attribute

Integer version of the state bundle layout.

Bumped whenever the file layout changes in a backwards-incompatible way. The OCI hook hard-fails if the annotation version does not match — deliberately no compatibility window and no migration: containers prepared by a different generation fail fast at restart with a message naming the remedy (re-create the task; a running container keeps running untouched, and the task workspace rides its mounts). The hook and package must agree on this protocol.

Current shape (v18): symbolic dnsmasq launch choice is separate from live process identity. Global hooks are setup-owned, not per-container files.

v16: v15 plus two derived seed caches — override_resolved.ips (t10 break-glass) and deny_resolved.ips (t20 security-deny). Both tiers are now statically resolved, so each is repopulated by address on every shield down/up rebuild instead of depending on the DNS plane to re-learn it. (v15 replaced the six v14 split allow/deny files with the tiered policy/ bundle of unified +/- files plus the derived resolved.ips cache.) Earlier shapes are recoverable via git log -L /^BUNDLE_VERSION/:src/terok_shield/state.py.

POLICY_DIR = 'policy' module-attribute

TIER_FILES = {'override': '10-override', 'security_deny': '20-security-deny', 'provider_allow': '30-provider-allow', 'project_allow': '40-project-allow'} module-attribute

LIVE_FILE = 'live' module-attribute

STATE_DIR_MODE = 448 module-attribute

Permission mode for state_dir and its subdirectories.

Owner-only. The OCI hook in _oci_state.py rejects state_dir if st_mode & 0o022 (group- or world-writable), because a loose mode would let any local peer drop a ruleset.nft for the hook to apply with CAP_NET_ADMIN. mkdir(mode=…) is masked by umask, so the writer side has to chmod after creation to guarantee the bit pattern the validator demands.

EffectivePolicy(override, security_deny, provider_allow, project_allow, live) dataclass

Per-tier policy entries read from the bundle, in authority order.

live is the runtime overlay (shield allow/deny); the engine folds its + entries into the project-allow set and its - entries into the security-deny set.

override instance-attribute

security_deny instance-attribute

provider_allow instance-attribute

project_allow instance-attribute

live instance-attribute

all_entries()

Every entry across tiers, top-to-bottom in authority order.

Source code in src/terok_shield/state.py
def all_entries(self) -> list[PolicyEntry]:
    """Every entry across tiers, top-to-bottom in authority order."""
    return [
        *self.override,
        *self.security_deny,
        *self.provider_allow,
        *self.project_allow,
        *self.live,
    ]

localhost_ports()

Host-service ports granted by +localhost:PORT across every tier.

Source code in src/terok_shield/state.py
def localhost_ports(self) -> tuple[int, ...]:
    """Host-service ports granted by ``+localhost:PORT`` across every tier."""
    return localhost_ports(self.all_entries())

allow_domains()

Domains to admit — fed to dnsmasq's nftset auto-population.

Source code in src/terok_shield/state.py
def allow_domains(self) -> list[str]:
    """Domains to admit — fed to dnsmasq's nftset auto-population."""
    return _dedup(domain_targets(self._allows()))

deny_domains()

Domains to refuse — withheld from dnsmasq's allow set.

Source code in src/terok_shield/state.py
def deny_domains(self) -> list[str]:
    """Domains to refuse — withheld from dnsmasq's allow set."""
    return _dedup(domain_targets(self._denies()))

dnsmasq_domains()

Effective dnsmasq nftset list: admitted domains minus denied.

Source code in src/terok_shield/state.py
def dnsmasq_domains(self) -> list[str]:
    """Effective dnsmasq nftset list: admitted domains minus denied."""
    denied = set(self.deny_domains())
    return [d for d in self.allow_domains() if d not in denied]

deny_ips()

Literal denied IPs — the non-resolved part of the tier-20 set seed.

Source code in src/terok_shield/state.py
def deny_ips(self) -> list[str]:
    """Literal denied IPs — the non-resolved part of the tier-20 set seed."""
    return _dedup(ip_targets(self._denies()))

deny_targets()

Denied domains + literal IPs to resolve (localhost excluded) — the deny-resolver input.

The t20 security-deny must hold addresses to survive a shield down rebuild and to catch direct-IP access that never touches the DNS plane, so its domains are statically resolved into deny_resolved (mirroring the t10 override treatment).

Source code in src/terok_shield/state.py
def deny_targets(self) -> list[str]:
    """Denied domains + literal IPs to resolve (``localhost`` excluded) — the deny-resolver input.

    The t20 security-deny must hold *addresses* to survive a
    ``shield down`` rebuild and to catch direct-IP access that never
    touches the DNS plane, so its domains are statically resolved into
    [`deny_resolved`][terok_shield.state.StateBundle.deny_resolved]
    (mirroring the t10 override treatment)."""
    return _dedup([e.target for e in self._denies() if e.target != LOCALHOST])

effective_ips()

Admitted literal IPs minus denied (the non-resolved part of the set seed).

Source code in src/terok_shield/state.py
def effective_ips(self) -> list[str]:
    """Admitted literal IPs minus denied (the non-resolved part of the set seed)."""
    denied = set(self.deny_ips())
    return [ip for ip in _dedup(ip_targets(self._allows())) if ip not in denied]

allow_targets()

Admitted domains + literal IPs to resolve (localhost excluded) — the resolver input.

Source code in src/terok_shield/state.py
def allow_targets(self) -> list[str]:
    """Admitted domains + literal IPs to resolve (``localhost`` excluded) — the resolver input."""
    return _dedup([e.target for e in self._allows() if e.target != LOCALHOST])

override_targets()

Break-glass override domains + literal IPs to resolve (localhost excluded).

The t10 override is a separate above-deny nft set — not part of the ordinary allow tiers (allow_targets), so it is resolved and seeded independently.

Source code in src/terok_shield/state.py
def override_targets(self) -> list[str]:
    """Break-glass override domains + literal IPs to resolve (``localhost`` excluded).

    The t10 override is a *separate* above-deny nft set — not part of the
    ordinary allow tiers
    ([`allow_targets`][terok_shield.state.EffectivePolicy.allow_targets]),
    so it is resolved and seeded independently.
    """
    return _dedup(
        [e.target for e in self.override if e.action == "+" and e.target != LOCALHOST]
    )

wildcard_domains()

Admitted *. entries; only a tier that resolves live can enforce them.

Source code in src/terok_shield/state.py
def wildcard_domains(self) -> list[str]:
    """Admitted ``*.`` entries; only a tier that resolves live can enforce them."""
    return [d for d in self.allow_domains() if d.startswith("*.")]

override_domains()

Break-glass override domains — the DNS-plane punch-through set.

A t10 override host is usually also denied by t20 (that is the point of an override), so the dnsmasq sinkhole generator must treat these names as allowed — otherwise the override host would NXDOMAIN and the statically seeded t10 set would never see a connection.

Source code in src/terok_shield/state.py
def override_domains(self) -> list[str]:
    """Break-glass override domains — the DNS-plane punch-through set.

    A t10 override host is usually *also* denied by t20 (that is the
    point of an override), so the dnsmasq sinkhole generator must treat
    these names as allowed — otherwise the override host would NXDOMAIN
    and the statically seeded t10 set would never see a connection.
    """
    return _dedup(domain_targets([e for e in self.override if e.action == "+"]))

StateBundle(state_dir) dataclass

File-layout contract for a single shielded container's state_dir.

Frozen so the per-task instance is safe to pass through hook callbacks without anyone smuggling a mutated state_dir into a later stage. Every property is a pure derivation off state_dir; the IO methods (read_effective, read_effective_ips, read_denied_ips, ensure_dirs) bundle the small handful of read-and-compose / setup helpers that previously floated as free functions taking state_dir repeatedly.

state_dir instance-attribute

ruleset property

Path to the pre-generated nft ruleset file.

upstream_dns property

Path to the persisted upstream DNS address.

dns_tier property

Path to the persisted DNS tier value.

network_mode property

Path to the persisted rootless network mode (pasta/slirp4netns).

Detected once at pre_start and read back by HookMode.refresh, which derives the ruleset's gateway addresses from it — a restart rebuilds the bundle without paying for a podman info probe, and cannot pick a mode the running container was not launched with.

loopback_ports property

Path to the per-container host-loopback TCP ports list.

Written by HookMode.pre_start from the caller-supplied ShieldConfig.loopback_ports (the per-container triple of gate / token-broker / ssh-signer ports the supervisor binds). Read back by shield_up / shield_down when they rebuild the nft ruleset — so a fresh Shield constructed without the override still emits the correct tcp dport <p> ip daddr 10.0.2.2 accept rules.

policy_dir property

Directory holding the per-tier +/- policy files.

policy_live property

Path to the runtime overlay (shield allow/deny append here).

resolved_cache property

Derived per-container cache of resolved allow IPs (the t40 set seed).

Separate from the authored policy/ tiers so resolution can be reused across task starts and invalidated independently — keyed on policy_mtime.

override_resolved property

Derived per-container cache of resolved override IPs (the t10 set seed).

The t10 override sits above the security-deny tier and is a separate nft set, so it is resolved and cached apart from the allow tiers. Statically resolved at pre_start — break-glass entries are rare and specific, and dnsmasq interception would populate t40 (below the deny), defeating the override.

deny_resolved property

Derived per-container cache of resolved security-deny IPs (the t20 set seed).

Denied domains must reach the packet filter as addresses: the deny set is what survives a shield down (the down posture keeps enforcing it), and an address-level deny also catches direct-IP access that never consults the DNS plane. Statically resolved at pre_start on every DNS tier, cached apart from the allow-side resolved.ips so the two invalidate independently.

dnsmasq_conf property

Path to the generated dnsmasq configuration file.

dnsmasq_pid property

Path to the dnsmasq PID file (PID is in the container netns).

dnsmasq_command property

Path to the symbolic or explicitly configured dnsmasq launch choice.

dnsmasq_bin property

Path to the live dnsmasq executable identity, never a launch choice.

dnsmasq_log property

Path to the dnsmasq query log (consumed by shield watch).

resolv_conf property

Path to the resolv.conf bind-mounted over /etc/resolv.conf on every tier.

container_id property

Path to the persisted podman container ID file.

reader_pid property

Path where the bridge hook tracks the live NFLOG reader PID.

audit property

Path to the per-container audit log.

meta_path property

Persisted-meta-path pointer file under state_dir.

Mirrors the resource-side META_PATH_FILE_NAME constant — one filename on both sides of the hook boundary so package code that reads it (Shield.up()/down()) and resource code that writes it (the bridge createRuntime hook) can never drift on path convention.

read_dns_tier()

The tier pre_start recorded for this container, or None when there is none.

None means the container was never shielded, or the file is not a tier name; a retired name reads as the tier it named.

Source code in src/terok_shield/state.py
def read_dns_tier(self) -> DnsTier | None:
    """The tier ``pre_start`` recorded for this container, or ``None`` when there is none.

    ``None`` means the container was never shielded, or the file is not a
    tier name; a retired name reads as the tier it named.
    """
    try:
        return DnsTier.parse(self.dns_tier.read_text().strip())
    except (OSError, ValueError):  # absent, or non-UTF-8 content
        return None

read_loopback_ports()

Read persisted loopback ports; empty tuple when the file is absent.

Source code in src/terok_shield/state.py
def read_loopback_ports(self) -> tuple[int, ...]:
    """Read persisted loopback ports; empty tuple when the file is absent."""
    if not self.loopback_ports.is_file():
        return ()
    return tuple(
        int(line.strip())
        for line in self.loopback_ports.read_text().splitlines()
        if line.strip()
    )

tier_path(tier)

Path to one tier's policy file (tier is a TIER_FILES key).

Source code in src/terok_shield/state.py
def tier_path(self, tier: str) -> Path:
    """Path to one tier's policy file (``tier`` is a [`TIER_FILES`][terok_shield.state.TIER_FILES] key)."""
    return self.policy_dir / TIER_FILES[tier]

policy_mtime()

Newest mtime among the policy files (0.0 when none exist yet).

Feeds the resolver's content-aware freshness check: a resolved cache older than this means the authored allowlist changed since we resolved.

Source code in src/terok_shield/state.py
def policy_mtime(self) -> float:
    """Newest mtime among the policy files (``0.0`` when none exist yet).

    Feeds the resolver's content-aware freshness check: a resolved cache
    older than this means the authored allowlist changed since we resolved.
    """
    mtimes = [
        p.stat().st_mtime
        for p in (*(self.tier_path(t) for t in TIER_FILES), self.policy_live)
        if p.is_file()
    ]
    return max(mtimes, default=0.0)

read_tier(path)

Parse one policy file; an absent file is an empty tier.

Source code in src/terok_shield/state.py
def read_tier(self, path: Path) -> list[PolicyEntry]:
    """Parse one policy file; an absent file is an empty tier."""
    return parse_policy(path.read_text()) if path.is_file() else []

write_tier(tier, content)

Write a tier file only when content differs.

Skipping no-op writes preserves the file's mtime, which the resolver's content-aware freshness keys on — so an unchanged allowlist stays a cache hit across task starts instead of forcing a re-resolution.

Source code in src/terok_shield/state.py
def write_tier(self, tier: str, content: str) -> None:
    """Write a tier file only when *content* differs.

    Skipping no-op writes preserves the file's mtime, which the resolver's
    content-aware freshness keys on — so an unchanged allowlist stays a
    cache hit across task starts instead of forcing a re-resolution.
    """
    path = self.tier_path(tier)
    if not path.is_file() or path.read_text() != content:
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(content)

read_effective()

Read and compose every tier into an EffectivePolicy.

Source code in src/terok_shield/state.py
def read_effective(self) -> EffectivePolicy:
    """Read and compose every tier into an [`EffectivePolicy`][terok_shield.state.EffectivePolicy]."""
    return EffectivePolicy(
        override=self.read_tier(self.tier_path("override")),
        security_deny=self.read_tier(self.tier_path("security_deny")),
        provider_allow=self.read_tier(self.tier_path("provider_allow")),
        project_allow=self.read_tier(self.tier_path("project_allow")),
        live=self.read_tier(self.policy_live),
    )

overlay_set(action, target)

Upsert {action}{target} into the runtime overlay (policy/live).

The target is validated through the parser (a malformed domain/IP raises). Any prior entry for target is dropped first, so a later shield allow flips an earlier deny (and vice-versa) rather than stacking.

Source code in src/terok_shield/state.py
def overlay_set(self, action: Action, target: str) -> None:
    """Upsert ``{action}{target}`` into the runtime overlay (``policy/live``).

    The target is validated through the parser (a malformed domain/IP
    raises).  Any prior entry for *target* is dropped first, so a later
    ``shield allow`` flips an earlier ``deny`` (and vice-versa) rather
    than stacking.
    """
    (entry,) = parse_policy(f"{action}{target}")
    kept = [e for e in self.read_tier(self.policy_live) if e.target != entry.target]
    kept.append(entry)
    self.policy_live.parent.mkdir(parents=True, exist_ok=True)
    self.policy_live.write_text(render_policy(kept))

read_denied_ips()

The tier-20 security-deny set seed: literal denied IPs + resolved denied domains.

Unions the current literal - IPs (security-deny tier + runtime overlay) with the statically resolved deny_resolved cache. This is what shield down/up repopulate the deny set from — a denied domain must keep denying by address across every rebuild, or the down posture would silently un-deny it.

Source code in src/terok_shield/state.py
def read_denied_ips(self) -> set[str]:
    """The tier-20 security-deny set seed: literal denied IPs + resolved denied domains.

    Unions the current literal ``-`` IPs (security-deny tier + runtime
    overlay) with the statically resolved
    [`deny_resolved`][terok_shield.state.StateBundle.deny_resolved]
    cache.  This is what ``shield down``/``up`` repopulate the deny set
    from — a denied *domain* must keep denying by address across every
    rebuild, or the down posture would silently un-deny it.
    """
    return set(self.read_effective().deny_ips()) | set(_read_cached_ips(self.deny_resolved))

read_effective_ips()

The tier-40 project-allow set seed: resolved allow IPs minus denied.

Unions the derived resolved_cache (literal allow IPs plus resolved allow-domains, refreshed at pre_start) with the policy tiers' current literal allow IPs — so a runtime shield allow of a raw IP survives a shield up rebuild even before the next resolution — then subtracts the denied IPs.

Source code in src/terok_shield/state.py
def read_effective_ips(self) -> list[str]:
    """The tier-40 project-allow set seed: resolved allow IPs minus denied.

    Unions the derived [`resolved_cache`][terok_shield.state.StateBundle.resolved_cache]
    (literal allow IPs plus resolved allow-domains, refreshed at pre_start)
    with the policy tiers' current literal allow IPs — so a runtime
    ``shield allow`` of a raw IP survives a ``shield up`` rebuild even
    before the next resolution — then subtracts the denied IPs.
    """
    eff = self.read_effective()
    denied = set(eff.deny_ips())
    seed = [ip for ip in _read_cached_ips(self.resolved_cache) if ip not in denied]
    return _dedup(seed + eff.effective_ips())

read_override_ips()

The tier-10 override set seed: literal override IPs + resolved override domains.

Unions the current literal + override IPs with the statically resolved override_resolved cache. Denies are not subtracted — the whole point of an override is to sit above the security-deny tier.

Source code in src/terok_shield/state.py
def read_override_ips(self) -> list[str]:
    """The tier-10 override set seed: literal override IPs + resolved override domains.

    Unions the current literal ``+`` override IPs with the statically
    resolved [`override_resolved`][terok_shield.state.StateBundle.override_resolved]
    cache.  Denies are *not* subtracted — the whole point of an override is
    to sit above the security-deny tier.
    """
    eff = self.read_effective()
    literal = ip_targets([e for e in eff.override if e.action == "+"])
    return _dedup(literal + _read_cached_ips(self.override_resolved))

ensure_dirs()

Create the state directory and its required subdirectories.

Both directories are forced to STATE_DIR_MODE (0o700) on every call — the OCI hook rejects anything looser, and a prior run under a permissive umask (Fedora's default 0o002 is a common offender) would otherwise leave the bundle stranded.

Source code in src/terok_shield/state.py
def ensure_dirs(self) -> None:
    """Create the state directory and its required subdirectories.

    Both directories are forced to
    [`STATE_DIR_MODE`][terok_shield.state.STATE_DIR_MODE]
    (``0o700``) on every call — the OCI hook rejects anything
    looser, and a prior run under a permissive ``umask`` (Fedora's
    default ``0o002`` is a common offender) would otherwise leave
    the bundle stranded.
    """
    self.state_dir.mkdir(parents=True, exist_ok=True)
    self.state_dir.chmod(STATE_DIR_MODE)
    self.policy_dir.mkdir(parents=True, exist_ok=True)
    self.policy_dir.chmod(STATE_DIR_MODE)

recorded_dns_tier(state_dir)

The DNS tier a shielded container launched with, read from state_dir.

Thin public wrapper over StateBundle.read_dns_tier so callers that only want the tier need not know the bundle layout.

Source code in src/terok_shield/state.py
def recorded_dns_tier(state_dir: Path) -> DnsTier | None:
    """The DNS tier a shielded container launched with, read from *state_dir*.

    Thin public wrapper over
    [`StateBundle.read_dns_tier`][terok_shield.state.StateBundle.read_dns_tier]
    so callers that only want the tier need not know the bundle layout.
    """
    return StateBundle(state_dir).read_dns_tier()