Skip to content

terok_shield

terok_shield

terok-shield: nftables-based egress firewalling for Podman containers.

Public API facade. The Shield class coordinates collaborators:

  • HookMode (hooks.mode) — per-container nft ruleset lifecycle
  • DnsResolver (dns.resolver) — domain resolution and caching
  • ProfileLoader (profiles) — allowlist profile composition
  • RulesetBuilder (nft.rules) — nftables ruleset generation
  • AuditLogger (audit) — per-container JSONL audit trail
  • CommandRunner (run) — subprocess execution boundary

Core and support modules are imported lazily — from terok_shield import ShieldConfig does not pull in nft, dnsmasq, or subprocess helpers. Heavy imports are deferred until Shield is instantiated.

HOOK_ENTRYPOINT_NAME = 'terok-shield-hook' module-attribute

Canonical filename of the shield OCI hook entrypoint script.

Used (a) under ~/.local/share/containers/oci/hooks.d/ for user-wide installation and (b) under each per-container state_dir after Shield.pre_start() materialises it. Keeping both sites consuming the same constant means renaming the entrypoint is a single edit.

COMMANDS = (CommandDef(name='status', help='Show shield configuration overview', handler=_handle_status, args=(ArgDef(name='container', nargs='?', help='Container name — prints firewall state (up/down/disengaged/offline/error)'),)), CommandDef(name='prepare', help='Prepare shield and print podman flags', extras=_NEEDS_CTR_STANDALONE, args=(ArgDef(name='--profiles', type=_csv_list, help="Override default profiles (comma-separated, e.g. 'dev,pypi')"), ArgDef(name='--json', action='store_true', dest='output_json', help='JSON output'))), CommandDef(name='run', help='Launch a shielded container via podman', extras=_NEEDS_CTR_STANDALONE, args=(ArgDef(name='--profiles', type=_csv_list, help="Override default profiles (comma-separated, e.g. 'dev,pypi')"),)), CommandDef(name='resolve', help='Resolve DNS profiles and cache IPs', extras=_NEEDS_CTR_STANDALONE, args=(ArgDef(name='--force', action='store_true', help='Bypass cache freshness'),)), CommandDef(name='allow', help='Live-allow a domain or IP for a container', handler=_handle_allow, extras=_NEEDS_CTR, args=(ArgDef(name='target', help='Domain name or IP address to allow'),)), CommandDef(name='deny', help='Live-deny a domain or IP for a container', handler=_handle_deny, extras=_NEEDS_CTR, args=(ArgDef(name='target', help='Domain name or IP address to deny'),)), CommandDef(name='down', help='Switch container to bypass mode (accept-all + log)', handler=_handle_down, extras=_NEEDS_CTR, args=(_CONTAINER_ID_ARG, ArgDef(name='--all', action='store_true', dest='allow_all', help='Also allow private-range traffic'))), CommandDef(name='up', help='Restore deny-all mode for a container', handler=_handle_up, extras=_NEEDS_CTR, args=(_CONTAINER_ID_ARG,)), CommandDef(name='quarantine', help='Total network blackout (drop all, log dropped traffic)', handler=_handle_quarantine, extras=_NEEDS_CTR), CommandDef(name='rules', help='Show current nft rules for a container', handler=_handle_rules, extras=_NEEDS_CTR), CommandDef(name='watch', help='Stream shield events — DNS blocks, audit log, NFLOG packets (requires dnsmasq tier)', handler=_handle_watch, extras=_NEEDS_CTR), CommandDef(name='simple-clearance', help='Terminal clearance fallback — prompts operator for each blocked connection (no D-Bus)', handler=_handle_simple_clearance, extras=_NEEDS_CTR), CommandDef(name='logs', help='Show audit log entries', handler=_handle_logs, extras=_NEEDS_CTR, args=(ArgDef(name='-n', type=int, default=50, help='Number of recent entries'),)), CommandDef(name='profiles', help='List available shield profiles', handler=_handle_profiles), CommandDef(name='setup', help='Install global OCI hooks for restart persistence', extras=_STANDALONE), CommandDef(name='check-environment', help='Check podman environment for compatibility issues', handler=_handle_check_environment), CommandDef(name='preview', help='Show ruleset that would be applied', handler=_handle_preview, args=(ArgDef(name='--down', action='store_true', help='Show bypass ruleset'), ArgDef(name='--all', action='store_true', dest='allow_all', help='Omit private-range reject rules (requires --down)')))) module-attribute

logger = logging.getLogger(__name__) module-attribute

__version__ = _meta_version('terok-shield') module-attribute

__all__ = ['ArgDef', 'BinaryCheck', 'COMMANDS', 'CommandDef', 'EnvironmentCheck', 'ExecError', 'HOOK_ENTRYPOINT_NAME', 'HooksInstaller', 'NftNotFoundError', 'Shield', 'ShieldConfig', 'ShieldMode', 'ShieldNeedsSetup', 'ShieldRuntime', 'ShieldState', 'check_firewall_binaries', 'check_krun_binaries', 'ensure_user_hooks_dir_configured'] module-attribute

ShieldConfig(state_dir, mode=ShieldMode.HOOK, default_profiles=('dev-standard',), loopback_ports=(), audit_enabled=True, profiles_dir=None, runtime=ShieldRuntime.DEFAULT) dataclass

Per-container shield configuration.

The library is a pure function of its inputs. Given a ShieldConfig with state_dir, it writes to that directory and nowhere else. No env-var reading, no config-file parsing.

state_dir instance-attribute

mode = ShieldMode.HOOK class-attribute instance-attribute

default_profiles = ('dev-standard',) class-attribute instance-attribute

loopback_ports = () class-attribute instance-attribute

audit_enabled = True class-attribute instance-attribute

profiles_dir = None class-attribute instance-attribute

runtime = ShieldRuntime.DEFAULT class-attribute instance-attribute

ShieldMode

Bases: Enum

Operating mode for the shield firewall.

Currently only HOOK is supported. Future modes (e.g. bridge) will add members here.

HOOK = 'hook' class-attribute instance-attribute

ShieldRuntime

Bases: Enum

Container runtime category — drives DNS-reachability assumptions.

crun / runc / youki. The container shares the netns,

so dnsmasq on 127.0.0.1 is reachable directly.

KRUN: libkrun microVM. The guest has its own loopback isolated from the netns, so dnsmasq must bind to a link-local address on netns lo that the guest can reach via passt.

DEFAULT = 'default' class-attribute instance-attribute

KRUN = 'krun' class-attribute instance-attribute

from_runtime_name(name) classmethod

Map a podman --runtime <name> string (or None) to the enum.

Centralises the wire-format vocabulary so callers don't repeat "krun" → KRUN mappings inline. Anything other than "krun" (including None and unknown runtime names) maps to DEFAULT — the loopback-shared-with-netns assumption holds for every runtime shield has been tested against besides krun.

Source code in src/terok_shield/config.py
@classmethod
def from_runtime_name(cls, name: str | None) -> ShieldRuntime:
    """Map a podman ``--runtime <name>`` string (or ``None``) to the enum.

    Centralises the wire-format vocabulary so callers don't repeat
    ``"krun" → KRUN`` mappings inline.  Anything other than
    ``"krun"`` (including ``None`` and unknown runtime names) maps
    to ``DEFAULT`` — the loopback-shared-with-netns assumption holds
    for every runtime shield has been tested against besides krun.
    """
    return cls.KRUN if name == "krun" else cls.DEFAULT

ShieldState

Bases: Enum

Per-container shield state, derived from the live nft ruleset.

QUARANTINE: Total network blackout — all traffic dropped, dropped traffic logged. UP: Normal enforcing mode (deny-all with allowlists). DOWN: Bypass mode with private-range protection (RFC 1918 + RFC 4193). DISENGAGED: Bypass mode without private-range protection. OFFLINE: No ruleset found (container stopped or unshielded). ERROR: Ruleset present but unrecognised.

QUARANTINE = 'quarantine' class-attribute instance-attribute

UP = 'up' class-attribute instance-attribute

DOWN = 'down' class-attribute instance-attribute

DISENGAGED = 'disengaged' class-attribute instance-attribute

OFFLINE = 'offline' class-attribute instance-attribute

ERROR = 'error' class-attribute instance-attribute

HooksInstaller(target_dir=_default_target_dir()) dataclass

Persistent installation of terok-shield's OCI hook pair.

The createRuntime/poststop hook pair must persist across container restarts: podman ≥ 5.x drops per-container --hooks-dir on stop/start (containers/podman#17935), so global hooks are the only reliable activation path until that upstream regression is fixed.

Scripts, ballast, and JSON descriptors all land in target_dir (default: namespace_state_dir("shield") / "hooks"). containers.conf is patched to register that path so podman discovers the descriptors on the next container start.

Symmetric lifecycle: install writes, uninstall removes. Both are idempotent.

target_dir = field(default_factory=_default_target_dir) class-attribute instance-attribute

Directory the hook scripts, ballast, and JSON descriptors all live in.

install()

Write entrypoints, ballast, and descriptors to target_dir.

Both hook pairs (nft + reader) and the shared ballast are written unconditionally — the reader hook soft-fails on missing clearance, so installing it on a shield-only host costs nothing and removes a configuration knob. The standalone NFLOG reader resource is copied to its canonical per-user path.

containers.conf is patched to list target_dir in hooks_dir so podman discovers the descriptors.

Source code in src/terok_shield/hooks/install.py
def install(self) -> None:
    """Write entrypoints, ballast, and descriptors to ``target_dir``.

    Both hook pairs (nft + reader) and the shared ballast are
    written unconditionally — the reader hook soft-fails on
    missing clearance, so installing it on a shield-only host
    costs nothing and removes a configuration knob.  The
    standalone NFLOG reader resource is copied to its canonical
    per-user path.

    ``containers.conf`` is patched to list ``target_dir`` in
    ``hooks_dir`` so podman discovers the descriptors.
    """
    install_reader_resource()
    self.target_dir.mkdir(parents=True, exist_ok=True)
    _write_role_files(self.target_dir, self.target_dir)
    ensure_user_hooks_dir_configured(self.target_dir)

uninstall()

Remove every hook file install would write.

Idempotent — missing files are tolerated. containers.conf is left untouched: other terok packages may still register their own hooks_dir entries the operator wants to keep.

Source code in src/terok_shield/hooks/install.py
def uninstall(self) -> None:
    """Remove every hook file [`install`][terok_shield.hooks.install.HooksInstaller.install] would write.

    Idempotent — missing files are tolerated.  ``containers.conf``
    is left untouched: other terok packages may still register
    their own ``hooks_dir`` entries the operator wants to keep.
    """
    for name in (*_SCRIPT_FILES, *_DESCRIPTOR_FILES):
        (self.target_dir / name).unlink(missing_ok=True)

is_installed()

True when target_dir carries the canonical createRuntime hook JSON.

A presence probe, not a version check — the Shield.check_environment path compares the ballast's BUNDLE_VERSION separately.

Source code in src/terok_shield/hooks/install.py
def is_installed(self) -> bool:
    """True when ``target_dir`` carries the canonical createRuntime hook JSON.

    A presence probe, not a version check — the
    [`Shield.check_environment`][terok_shield.Shield.check_environment]
    path compares the ballast's ``BUNDLE_VERSION`` separately.
    """
    return (self.target_dir / _nft_hook_json("createRuntime")).is_file()

ExecError(cmd, rc, stderr)

Bases: Exception

Raised when a subprocess fails.

Store command details and format the error message.

Source code in src/terok_shield/run.py
def __init__(self, cmd: list[str], rc: int, stderr: str) -> None:
    """Store command details and format the error message."""
    self.cmd = cmd
    self.rc = rc
    self.stderr = stderr
    super().__init__(f"{cmd!r} failed (rc={rc}): {stderr.strip()}")

cmd = cmd instance-attribute

rc = rc instance-attribute

stderr = stderr instance-attribute

EnvironmentCheck(dns_tier='', ok=True, podman_version=(0,), hooks='per-container', health='ok', issues=list(), needs_setup=False, setup_hint='') dataclass

Result of Shield.check_environment.

Machine-readable fields for programmatic consumers (terok TUI, scripts). Human-readable issues and setup_hint for CLI display.

Attributes:

Name Type Description
ok bool

True if no issues found.

podman_version tuple[int, ...]

Detected podman version tuple.

hooks str

Hook installation type (per-container, global, not-installed).

health str

Environment health (ok, setup-needed, stale-hooks).

dns_tier str

Active DNS resolution tier (dnsmasq, dig, getent).

issues list[str]

List of human-readable issue descriptions.

needs_setup bool

True if one-time setup is required.

setup_hint str

Setup instructions (empty if not needed).

dns_tier = '' class-attribute instance-attribute

ok = True class-attribute instance-attribute

podman_version = (0,) class-attribute instance-attribute

hooks = 'per-container' class-attribute instance-attribute

health = 'ok' class-attribute instance-attribute

issues = field(default_factory=list) class-attribute instance-attribute

needs_setup = False class-attribute instance-attribute

setup_hint = '' class-attribute instance-attribute

Shield(config, *, runner=None, audit=None, dns=None, profiles=None, ruleset=None, hub_events=None)

Public API facade — coordinates collaborators per container.

Delegates to HookMode for netns/nft operations, DnsResolver for name resolution, ProfileLoader for allowlists, RulesetBuilder for ruleset generation, and AuditLogger for the audit trail. All collaborators are injectable for testing.

Create the shield facade.

Parameters:

Name Type Description Default
config ShieldConfig

Shield configuration (must include state_dir).

required
runner CommandRunner | None

Command runner (default: SubprocessRunner).

None
audit AuditLogger | None

Audit logger (default: from config.state_dir).

None
dns DnsResolver | None

DNS resolver (default: from runner).

None
profiles ProfileLoader | None

Profile loader (default: from config.profiles_dir).

None
ruleset RulesetBuilder | None

Ruleset builder (default: from config loopback_ports).

None
hub_events HubEventEmitter | None

Best-effort emitter for shield_up / shield_down events bound for the terok-clearance hub (default: a fresh HubEventEmitter). The emitter routes each event to the supervisor's per-container socket using the container_id supplied on every up / down call. Pass a no-op stub in tests that should not touch the socket.

None
Source code in src/terok_shield/__init__.py
def __init__(
    self,
    config: ShieldConfig,
    *,
    runner: "CommandRunner | None" = None,
    audit: "AuditLogger | None" = None,
    dns: "DnsResolver | None" = None,
    profiles: "ProfileLoader | None" = None,
    ruleset: "RulesetBuilder | None" = None,
    hub_events: "HubEventEmitter | None" = None,
) -> None:
    """Create the shield facade.

    Args:
        config: Shield configuration (must include state_dir).
        runner: Command runner (default: ``SubprocessRunner``).
        audit: Audit logger (default: from config.state_dir).
        dns: DNS resolver (default: from runner).
        profiles: Profile loader (default: from config.profiles_dir).
        ruleset: Ruleset builder (default: from config loopback_ports).
        hub_events: Best-effort emitter for ``shield_up`` / ``shield_down``
            events bound for the terok-clearance hub (default: a fresh
            [`HubEventEmitter`][terok_shield._hub_events.HubEventEmitter]).  The
            emitter routes each event to the supervisor's
            per-container socket using the ``container_id`` supplied
            on every [`up`][terok_shield.Shield.up] /
            [`down`][terok_shield.Shield.down] call.  Pass a no-op
            stub in tests that should not touch the socket.
    """
    from ._hub_events import HubEventEmitter
    from .audit import AuditLogger
    from .dns.resolver import DnsResolver
    from .nft.rules import RulesetBuilder
    from .profiles import ProfileLoader
    from .run import SubprocessRunner

    self.config = config
    self.runner = runner or SubprocessRunner()
    self.audit = audit or AuditLogger(
        audit_path=StateBundle(config.state_dir).audit,
        enabled=config.audit_enabled,
    )
    self.dns = dns or DnsResolver(runner=self.runner)
    self.profiles = profiles or ProfileLoader(
        user_dir=config.profiles_dir or Path("/nonexistent"),
    )
    self.ruleset = ruleset or RulesetBuilder(loopback_ports=config.loopback_ports)
    self.hub_events = hub_events or HubEventEmitter()
    self._mode = self._create_mode(config.mode)

config = config instance-attribute

runner = runner or SubprocessRunner() instance-attribute

audit = audit or AuditLogger(audit_path=(StateBundle(config.state_dir).audit), enabled=(config.audit_enabled)) instance-attribute

dns = dns or DnsResolver(runner=(self.runner)) instance-attribute

profiles = profiles or ProfileLoader(user_dir=(config.profiles_dir or Path('/nonexistent'))) instance-attribute

ruleset = ruleset or RulesetBuilder(loopback_ports=(config.loopback_ports)) instance-attribute

hub_events = hub_events or HubEventEmitter() instance-attribute

check_environment()

Check the podman environment for compatibility issues.

Proactive check for API consumers (e.g. terok). Returns an EnvironmentCheck with detected issues and setup hints. Does not raise — the caller decides how to handle issues.

Source code in src/terok_shield/__init__.py
def check_environment(self) -> EnvironmentCheck:
    """Check the podman environment for compatibility issues.

    Proactive check for API consumers (e.g. terok).  Returns an
    [`EnvironmentCheck`][terok_shield.EnvironmentCheck] with detected issues and setup hints.
    Does not raise — the caller decides how to handle issues.
    """
    from . import state
    from .dns import apparmor

    output = self.runner.run(["podman", "info", "-f", "json"], check=False)
    info = parse_podman_info(output)
    issues: list[str] = []
    needs_setup = False
    setup_hint = ""
    hooks = "per-container"
    health = "ok"

    tier, apparmor_blocked = apparmor.detect_dns_tier_under_apparmor(
        self.runner, self.config.state_dir
    )
    dns_tier = tier.value
    if apparmor_blocked:
        issues.append(
            "dnsmasq is present but AppArmor confines it from the shield "
            f"state directory — domain allowlisting falls back to static {tier.value} "
            "resolution (no IP rotation handling). Install the terok AppArmor "
            "profile to enable the dnsmasq tier (see docs/apparmor.md)"
        )
    elif tier == DnsTier.DIG:
        issues.append(
            "dnsmasq not found — domain allowlisting uses static pre-start "
            "resolution (no IP rotation handling). "
            "Install dnsmasq for dynamic domain-based egress control"
        )
    elif tier == DnsTier.GETENT:
        issues.append(
            "Neither dnsmasq nor dig found — DNS resolution uses getent "
            "(single IP, no AAAA). Install dnsmasq or at minimum dnsutils/bind-utils"
        )

    hooks_dirs = find_hooks_dirs()
    global_hooks = has_global_hooks(hooks_dirs)

    if not info.hooks_dir_persists:
        if global_hooks:
            hooks = "global"
            health = "ok"
            # Check hook version matches current package
            hook_ver = _read_installed_hook_version(hooks_dirs)
            if hook_ver != state.BUNDLE_VERSION:
                health = "stale-hooks"
                issues.append(
                    f"Installed hook version {hook_ver} != expected {state.BUNDLE_VERSION}. "
                    "Run `terok-shield setup` to update."
                )
        else:
            hooks = "not-installed"
            health = "setup-needed"
            needs_setup = True
            setup_hint = global_hooks_hint()
            issues.append(
                "Global hooks not installed - containers will lose firewall on restart"
            )
    elif global_hooks:
        health = "stale-hooks"
        issues.append(
            "Stale global hooks detected - not needed on podman >= 5.6.0. "
            "Consider removing them."
        )

    return EnvironmentCheck(
        ok=not issues,
        podman_version=info.version,
        hooks=hooks,
        health=health,
        dns_tier=dns_tier,
        issues=issues,
        needs_setup=needs_setup,
        setup_hint=setup_hint,
    )

status()

Return current shield status information.

Source code in src/terok_shield/__init__.py
def status(self) -> dict:
    """Return current shield status information."""
    return {
        "mode": self.config.mode.value,
        "profiles": self.profiles.list_profiles(),
        "audit_enabled": self.config.audit_enabled,
    }

pre_start(container, profiles=None)

Prepare shield for container start. Returns extra podman args.

Source code in src/terok_shield/__init__.py
def pre_start(self, container: str, profiles: list[str] | None = None) -> list[str]:
    """Prepare shield for container start.  Returns extra podman args."""
    if profiles is None:
        profiles = list(self.config.default_profiles)
    result = self._mode.pre_start(container, profiles)
    self.audit.log_event(container, "setup", detail=f"profiles={','.join(profiles)}")
    return result

allow(container, target)

Live-allow a domain or IP for a running container.

Source code in src/terok_shield/__init__.py
def allow(self, container: str, target: str) -> list[str]:
    """Live-allow a domain or IP for a running container."""
    from .run import ExecError

    is_domain = not _is_ip(target)
    ips = [target] if not is_domain else self.dns.resolve_domains([target])
    allowed: list[str] = []
    for ip in ips:
        try:
            self._mode.allow_ip(container, ip)
        except (ExecError, OSError) as exc:
            logger.warning("allow_ip failed for %s on %s: %s", ip, container, exc)
            continue
        allowed.append(ip)
        self.audit.log_event(container, "allowed", dest=ip, detail=f"target={target}")
    # Update dnsmasq config for domain targets (so future IP rotations are captured)
    if is_domain and allowed:
        self._mode.allow_domain(target)
    return allowed

deny(container, target)

Live-deny a domain or IP for a running container.

Source code in src/terok_shield/__init__.py
def deny(self, container: str, target: str) -> list[str]:
    """Live-deny a domain or IP for a running container."""
    from .run import ExecError

    is_domain = not _is_ip(target)
    ips = [target] if not is_domain else self.dns.resolve_domains([target])
    denied: list[str] = []
    for ip in ips:
        try:
            self._mode.deny_ip(container, ip)
        except (ExecError, OSError) as exc:
            logger.warning("deny_ip failed for %s on %s: %s", ip, container, exc)
            continue
        denied.append(ip)
        self.audit.log_event(container, "denied", dest=ip, detail=f"target={target}")
    # Remove domain from dnsmasq config (stops future auto-population)
    if is_domain and denied:
        self._mode.deny_domain(target)
    return denied

rules(container)

Return current nft rules for a container.

Source code in src/terok_shield/__init__.py
def rules(self, container: str) -> str:
    """Return current nft rules for a container."""
    return self._mode.list_rules(container)

down(container, container_id, *, allow_all=False)

Switch a running container to bypass mode.

container is the operator-facing podman name (audit log key); container_id is the full podman UUID — the routing key for the per-container hub socket the supervisor listens on. The caller knows both at every emit site, so neither carries a default.

Source code in src/terok_shield/__init__.py
def down(self, container: str, container_id: str, *, allow_all: bool = False) -> None:
    """Switch a running container to bypass mode.

    *container* is the operator-facing podman name (audit log key);
    *container_id* is the full podman UUID — the routing key for
    the per-container hub socket the supervisor listens on.  The
    caller knows both at every emit site, so neither carries a
    default.
    """
    self._mode.shield_down(container, allow_all=allow_all)
    self.audit.log_event(
        container,
        "shield_down",
        detail="allow_all=True" if allow_all else None,
    )
    self.hub_events.shield_down(
        container,
        container_id,
        allow_all=allow_all,
        dossier=self._read_dossier(),
    )

quarantine(container)

Total network blackout — drop all traffic, log dropped traffic.

Source code in src/terok_shield/__init__.py
def quarantine(self, container: str) -> None:
    """Total network blackout — drop all traffic, log dropped traffic."""
    self._mode.shield_quarantine(container)
    self.audit.log_event(container, "shield_quarantine")

up(container, container_id)

Restore normal deny-all mode for a running container.

container / container_id — see down.

Source code in src/terok_shield/__init__.py
def up(self, container: str, container_id: str) -> None:
    """Restore normal deny-all mode for a running container.

    *container* / *container_id* — see
    [`down`][terok_shield.Shield.down].
    """
    self._mode.shield_up(container)
    self.audit.log_event(container, "shield_up")
    self.hub_events.shield_up(container, container_id, dossier=self._read_dossier())

state(container)

Query the live nft ruleset to determine a container's shield state.

Source code in src/terok_shield/__init__.py
def state(self, container: str) -> ShieldState:
    """Query the live nft ruleset to determine a container's shield state."""
    return self._mode.shield_state(container)

preview(*, down=False, allow_all=False)

Generate the ruleset that would be applied to a container.

Source code in src/terok_shield/__init__.py
def preview(self, *, down: bool = False, allow_all: bool = False) -> str:
    """Generate the ruleset that would be applied to a container."""
    return self._mode.preview(down=down, allow_all=allow_all)

resolve(profiles=None, *, force=False)

Resolve DNS profiles and cache the results.

Source code in src/terok_shield/__init__.py
def resolve(
    self,
    profiles: list[str] | None = None,
    *,
    force: bool = False,
) -> list[str]:
    """Resolve DNS profiles and cache the results."""
    if profiles is None:
        profiles = list(self.config.default_profiles)
    entries = self.profiles.compose_profiles(profiles)
    if not entries:
        return []
    max_age = 0 if force else 3600
    cache_path = StateBundle(self.config.state_dir).profile_allowed
    return self.dns.resolve_and_cache(entries, cache_path, max_age=max_age)

profiles_list()

List available profile names.

Source code in src/terok_shield/__init__.py
def profiles_list(self) -> list[str]:
    """List available profile names."""
    return self.profiles.list_profiles()

tail_log(n=50)

Yield the last n audit events.

Source code in src/terok_shield/__init__.py
def tail_log(self, n: int = 50) -> Iterator[dict]:
    """Yield the last *n* audit events."""
    return self.audit.tail_log(n)

compose_profiles(names)

Load and merge multiple profiles.

Source code in src/terok_shield/__init__.py
def compose_profiles(self, names: list[str]) -> list[str]:
    """Load and merge multiple profiles."""
    return self.profiles.compose_profiles(names)

__getattr__(name)

Lazy import for re-exported core/support layer names.

Source code in src/terok_shield/__init__.py
def __getattr__(name: str) -> object:
    """Lazy import for re-exported core/support layer names."""
    if name in _LAZY_IMPORTS:
        mod_path, attr = _LAZY_IMPORTS[name]
        mod = _importlib.import_module(mod_path)
        value = getattr(mod, attr)
        globals()[name] = value  # cache for subsequent access
        return value
    raise AttributeError(f"module {__name__!r} has no attribute {name!r}")