Skip to content

config_schema

config_schema

Pydantic schema for the sandbox-owned slice of the shared config.yml.

The ecosystem uses one ~/.config/terok/config.yml file shared across every package (Podman model — see terok_sandbox.paths for the prior decision around umbrella roots). Each package owns the schema for the sub-sections it consumes; higher-level packages compose the full file by importing from their dependencies.

This module is sandbox's contribution: the nine top-level sections sandbox actually reads (paths, credentials, vault, gate_server, services, shield, network, ssh, run), each strict on its own keys (extra="forbid"), wrapped in SandboxConfigView whose top level is tolerant (extra="allow") so unknown sections — those owned by terok-executor or terok — pass through silently when sandbox is run standalone.

Validation strategy:

  • Owned sub-sections are strict. A typo inside paths.rooot is rejected at load time with a clear pydantic error.
  • Unknown top-level sections are tolerated. Sandbox doesn't know about terok's tui: or executor's image:; rejecting them would make the standalone python -m terok_sandbox flow crash on any complete ecosystem config.

Higher-level packages inherit from SandboxConfigView and add their own sections. The topmost layer (terok) flips back to extra="forbid" because it knows every section in the v0 ecosystem.

ServicesMode = Literal['tcp', 'socket'] module-attribute

Type alias for the services.mode Literal; re-exported from RawServicesSection.model_fields['mode'] so downstream modules (sandbox's SandboxConfig, terok's make_sandbox_config) can annotate without re-declaring the shape.

SERVICES_TCP_OPTOUT_YAML = 'services: {mode: tcp}' module-attribute

User-facing opt-out snippet shown in SELinux hints — keep in one place so setup, sickbay, tests and docs stay in sync.

__all__ = ['SERVICES_TCP_OPTOUT_YAML', 'RawCredentialsSection', 'RawGateServerSection', 'RawNetworkSection', 'RawPathsSection', 'RawSSHSection', 'RawServicesSection', 'RawShieldSection', 'RawVaultSection', 'SandboxConfigView', 'ServicesMode', 'gate_use_personal_ssh_default'] module-attribute

RawCredentialsSection

Bases: BaseModel

The credentials: section — vault routing for proxy DB and agent mounts.

model_config = ConfigDict(extra='forbid') class-attribute instance-attribute

dir = Field(default=None, description='Shared credentials directory (proxy DB, agent config mounts)') class-attribute instance-attribute

passphrase = Field(default=None, description='Unsafe headless fallback for the SQLCipher passphrase; only set when no OS keyring or systemd-creds is available.') class-attribute instance-attribute

use_keyring = Field(default=False, description='Opt-in switch for the OS keyring tier of the passphrase resolution chain. Off by default because Linux Secret Service has per-collection (not per-item) ACLs.') class-attribute instance-attribute

passphrase_command = Field(default=None, description='Operator-supplied shell command (e.g. ``pass show terok-sandbox/vault-passphrase``) that prints the SQLCipher passphrase on stdout. Tokenised with ``shlex.split``; resolver tier slots between OS keyring and the plaintext config-file fallback. Canonical headless option for hosts without systemd ≥ 257 — covers ``pass``, ``bw``, ``op``, HashiCorp ``vault``, and the cloud secret-manager CLIs (AWS, GCP, Azure).') class-attribute instance-attribute

RawPathsSection

Bases: BaseModel

The paths: section — umbrella state root and per-purpose overrides.

root is the namespace state root read by every ecosystem package (Podman model — see also terok_sandbox.paths.umbrella_state_dir).

model_config = ConfigDict(extra='forbid') class-attribute instance-attribute

root = Field(default=None, description='Namespace state root shared by all ecosystem packages (Podman model — one config, multiple readers)') class-attribute instance-attribute

build_dir = Field(default=None, description='Build artifacts directory (generated Dockerfiles)') class-attribute instance-attribute

sandbox_live_dir = Field(default=None, description='Container-writable runtime data (tasks, agent mounts). For hardened installs, mount the target with ``noexec,nosuid,nodev``') class-attribute instance-attribute

user_projects_dir = Field(default=None, description='User projects directory (per-user project configs)') class-attribute instance-attribute

user_presets_dir = Field(default=None, description='User presets directory (per-user preset configs)') class-attribute instance-attribute

port_registry_dir = Field(default=None, description='Shared port registry directory for multi-user isolation') class-attribute instance-attribute

RawShieldSection

Bases: BaseModel

The shield: section — egress firewall policy + audit + task lifecycle defaults.

model_config = ConfigDict(extra='forbid') class-attribute instance-attribute

bypass_firewall_no_protection = Field(default=False, description='**Dangerous**: disable egress firewall entirely') class-attribute instance-attribute

profiles = Field(default=None, description='Named shield profiles for per-project firewall rules') class-attribute instance-attribute

audit = Field(default=True, description='Enable shield audit logging') class-attribute instance-attribute

drop_on_task_run = True class-attribute instance-attribute

on_task_restart = 'retain' class-attribute instance-attribute

RawServicesSection

Bases: BaseModel

The services: section — transport mode for host ↔ container IPC.

model_config = ConfigDict(extra='forbid') class-attribute instance-attribute

mode = 'socket' class-attribute instance-attribute

Transport for host↔container IPC. Default socket since 0.7.3; set to tcp to opt out. See docs/selinux.md.

RawVaultSection

Bases: BaseModel

The vault: section — token broker and SSH signer ports.

The container-side transport was previously configured via vault.transport; since 0.7.4 it is derived from services.mode so the two knobs stay in lockstep (tcp listener ↔ direct routing, socket listener ↔ socket routing). Any prior vault.transport: line in config.yml must be removed.

model_config = ConfigDict(extra='forbid') class-attribute instance-attribute

bypass_no_secret_protection = False class-attribute instance-attribute

port = Field(default=None, ge=1, le=65535) class-attribute instance-attribute

ssh_signer_port = Field(default=None, ge=1, le=65535) class-attribute instance-attribute

RawGateServerSection

Bases: BaseModel

The gate_server: section — host-side gate listen port + repo dir.

model_config = ConfigDict(extra='forbid') class-attribute instance-attribute

port = Field(default=None, ge=1, le=65535, description='Gate server listen port') class-attribute instance-attribute

repos_dir = Field(default=None, description='Override gate repo directory (default: ``state_dir/gate``)') class-attribute instance-attribute

suppress_systemd_warning = Field(default=False, description='Suppress the systemd unit installation suggestion') class-attribute instance-attribute

RawNetworkSection

Bases: BaseModel

The network: section — port range for service / container ports.

model_config = ConfigDict(extra='forbid') class-attribute instance-attribute

port_range_start = Field(default=18700, ge=1024, le=65535) class-attribute instance-attribute

port_range_end = Field(default=32700, ge=1024, le=65535) class-attribute instance-attribute

RawSSHSection

Bases: BaseModel

The ssh: section — auth strategy for the host-side gate.

Default is None (not False) so model_dump(exclude_none=True) can distinguish unset from explicitly false. Higher layers may layer this with a project.yml ssh: section of the same shape; the None sentinel keeps the project layer from stomping the global value when the user omits it. The effective False default happens at the consumer end.

model_config = ConfigDict(extra='forbid') class-attribute instance-attribute

use_personal = Field(default=None, description="Opt in to the user's ``~/.ssh`` keys for host-side ``gate-sync``. Default ``false`` — terok uses only its vault-managed key. Resolves through ConfigStack: ``terok-global config.yml`` → ``project.yml`` → CLI ``--use-personal-ssh`` (highest).") class-attribute instance-attribute

RawHooksSection

Bases: BaseModel

Task lifecycle hook commands.

Run on the host (not inside the container) around container lifecycle events. Sandbox owns them because the lifecycle events themselves are sandbox-mediated — the orchestrator just opts into being notified. The four hook points map to sandbox-internal transitions:

  • pre_start: before the container exists (host-side prep).
  • post_start: after the container is created but possibly not ready.
  • post_ready: after the readiness marker has been observed.
  • post_stop: after the container has stopped (cleanup hook).

Each value is a shell command string, run by the host shell with the orchestrator's environment. None means no hook.

model_config = ConfigDict(extra='forbid') class-attribute instance-attribute

pre_start = None class-attribute instance-attribute

post_start = None class-attribute instance-attribute

post_ready = None class-attribute instance-attribute

post_stop = None class-attribute instance-attribute

RawRunSection

Bases: BaseModel

The run: section — "how the container runs".

Covers OCI-runtime selection, container resource limits, capability toggles, environment, and lifecycle hooks. Sandbox owns this because every field translates to a podman/runtime flag or annotation sandbox emits at launch time.

Inheritable in both directions:

  • At the global level, defaults apply to every project (e.g. set runtime: krun once to opt the whole installation into microVM isolation).
  • At the project level, fields override the global default one-by-one via the orchestrator's merge logic.

model_config = ConfigDict(extra='forbid') class-attribute instance-attribute

shutdown_timeout = Field(default=10, description='Seconds to wait before SIGKILL on container stop') class-attribute instance-attribute

gpus = Field(default=None, description='GPU passthrough: ``true``, ``"all"``, or omit to disable') class-attribute instance-attribute

memory = Field(default=None, description='Podman ``--memory`` value (e.g. ``"4g"``, ``"512m"``, ``"4gib"``, plain ``"1024"`` for bytes); ``None`` = unlimited. Format mirrors what podman accepts — see ``man podman-run(1)`` --memory.') class-attribute instance-attribute

cpus = Field(default=None, description='Podman ``--cpus`` value (e.g. ``"2.0"``, ``"0.5"``); ``None`` = unlimited. Non-negative decimal.') class-attribute instance-attribute

nested_containers = Field(default=False, description='Declares that the project runs podman/docker inside its container. When true, the outer container is launched with ``--security-opt label=nested`` and ``--device /dev/fuse`` so rootless nested containers work under SELinux without disabling labels wholesale.') class-attribute instance-attribute

runtime = Field(default=None, description='OCI runtime: ``crun`` (default) for conventional containers, or ``krun`` for KVM-microVM isolation (experimental). ``None`` resolves to ``crun`` — the OCI runtime podman picks by default on every supported distro. ``krun`` requires the global ``experimental: true`` flag at task launch.') class-attribute instance-attribute

timezone = Field(default=None, description="IANA timezone for the task container (e.g. ``Europe/Prague``, ``UTC``). Propagated as ``TZ`` — resolved against the image's ``tzdata``. Unset (default) means follow the host's timezone.") class-attribute instance-attribute

hooks = Field(default_factory=RawHooksSection) class-attribute instance-attribute

SandboxConfigView

Bases: BaseModel

The slice of config.yml sandbox owns and validates.

extra="allow" at the top level so unknown sections (executor's image:, terok's tui: / logs: / tasks: / git: / hooks:) pass through silently when sandbox is run standalone — the ecosystem's shared config file is expected to contain every package's keys, and rejecting them would make python -m terok_sandbox crash on any complete config.

Higher layers compose by inheriting from this class and adding their own typed fields:

  • terok_executor.config_schema.ExecutorConfigView inherits and adds the image: section.
  • terok's RawGlobalConfig inherits and adds the remaining five terok-owned sections, then flips to extra="forbid" — the topmost layer knows every section, so a typo at the top level is caught there.

model_config = ConfigDict(extra='allow') class-attribute instance-attribute

credentials = Field(default_factory=RawCredentialsSection) class-attribute instance-attribute

paths = Field(default_factory=RawPathsSection) class-attribute instance-attribute

shield = Field(default_factory=RawShieldSection) class-attribute instance-attribute

services = Field(default_factory=RawServicesSection) class-attribute instance-attribute

vault = Field(default_factory=RawVaultSection) class-attribute instance-attribute

gate_server = Field(default_factory=RawGateServerSection) class-attribute instance-attribute

network = Field(default_factory=RawNetworkSection) class-attribute instance-attribute

ssh = Field(default_factory=RawSSHSection) class-attribute instance-attribute

run = Field(default_factory=RawRunSection) class-attribute instance-attribute

experimental = Field(default=False, description="Cross-package opt-in for experimental features. Gates terok's krun runtime and sandbox's krun-only host-binary prereq probes (``ip``). Lives on the top level rather than in any one section because it's shared between sandbox, executor, and terok — the topmost layer (terok) inherits this declaration.") class-attribute instance-attribute

gate_use_personal_ssh_default()

Resolve the host gate's ssh.use_personal global default.

Reads the ssh: section from the shared config.yml, validates via RawSSHSection, and returns the bool. An unset section, a missing key, or a malformed value collapses to False — the safe historical default ("terok never touches your real keys").

Higher layers compose this with project-level and per-invocation overrides; the resolution chain ends up:

CLI ``--use-personal-ssh``     (highest)
project ``project.yml`` ssh
global ``config.yml`` ssh      ← THIS function
False                          (default)

Lives in sandbox because the consumer (_git_env_with_ssh) is here too — same package owns the schema and the reader.

Source code in src/terok_sandbox/config_schema.py
def gate_use_personal_ssh_default() -> bool:
    """Resolve the host gate's ``ssh.use_personal`` global default.

    Reads the ``ssh:`` section from the shared ``config.yml``, validates
    via [`RawSSHSection`][terok_sandbox.config_schema.RawSSHSection], and returns the bool.  An unset section,
    a missing key, or a malformed value collapses to ``False`` — the
    safe historical default ("terok never touches your real keys").

    Higher layers compose this with project-level and per-invocation
    overrides; the resolution chain ends up:

        CLI ``--use-personal-ssh``     (highest)
        project ``project.yml`` ssh
        global ``config.yml`` ssh      ← THIS function
        False                          (default)

    Lives in sandbox because the consumer
    (`_git_env_with_ssh`) is here too —
    same package owns the schema and the reader.
    """
    from .paths import read_config_section

    raw = read_config_section("ssh")
    if not raw:
        return False
    try:
        section = RawSSHSection.model_validate(raw)
    except Exception:  # noqa: BLE001 — malformed config falls back to safe default
        return False
    return bool(section.use_personal)