Skip to content

launch

launch

Per-container wiring for user-owned containers.

prepare/run/cleanup compose podman flags that wire a caller-owned container into the sandbox's services (vault token broker, vault SSH signer, git gate, shield egress firewall) and persist enough per-container state for cleanup to be a no-arg reverse of prepare.

Container lifecycle stays with the user; sandbox owns only the services and the per-container ancillary state (tokens, shield rules, meta JSON).

CONTAINER_BRIDGES_DIR = '/usr/local/share/terok-sandbox/bridges' module-attribute

LOOPBACK_VAULT_PORT = 9419 module-attribute

SANDBOX_MANAGED_FLAGS = frozenset({'--name', '--network', '--hooks-dir', '--annotation', '--cap-add', '--cap-drop', '--userns'}) module-attribute

WiringPlan(scope, shield, gate, broker, ssh) dataclass

Subsystems activated for a single prepare/run invocation.

Persisted to meta.json so cleanup reverses exactly what was activated, without re-running the flag-defaults dance.

scope instance-attribute

shield instance-attribute

gate instance-attribute

broker instance-attribute

ssh instance-attribute

to_dict()

Return a JSON-serialisable representation.

Source code in src/terok_sandbox/launch.py
def to_dict(self) -> dict[str, object]:
    """Return a JSON-serialisable representation."""
    return {
        "scope": self.scope,
        "shield": self.shield,
        "gate": self.gate,
        "broker": self.broker,
        "ssh": self.ssh,
    }

from_dict(data) classmethod

Construct from a previously-persisted to_dict payload.

Source code in src/terok_sandbox/launch.py
@classmethod
def from_dict(cls, data: dict[str, object]) -> WiringPlan:
    """Construct from a previously-persisted ``to_dict`` payload."""
    return cls(
        scope=data.get("scope"),  # type: ignore[arg-type]
        shield=bool(data.get("shield")),
        gate=bool(data.get("gate")),
        broker=bool(data.get("broker")),
        ssh=bool(data.get("ssh")),
    )

needs_bridges()

True when any container-side bridge will be used.

Source code in src/terok_sandbox/launch.py
def needs_bridges(self) -> bool:
    """True when any container-side bridge will be used."""
    return self.gate or self.broker or self.ssh

PerContainerResources(container_runtime_dir, token_broker_port, ssh_signer_port, gate_port) dataclass

Per-container socket dir + (for TCP mode) ports.

Allocated once per launch so the same values reach mount flags, env vars, and the sidecar JSON the supervisor reads. Keeps concurrent containers from colliding on host-global filenames or ports.

container_runtime_dir instance-attribute

Host-side directory that becomes /run/terok/ inside the container. Contains the supervisor-bound vault.sock / ssh-agent.sock. Created (mode 0700) before the bind mount.

token_broker_port instance-attribute

Per-container TCP port for the vault proxy in TCP mode; None in socket mode.

ssh_signer_port instance-attribute

Per-container TCP port for the SSH signer in TCP mode; None in socket mode.

gate_port instance-attribute

Per-container TCP port for the git gate in TCP mode; None in socket mode.

allocate_per_container_resources(cfg, container)

Compute per-container paths + (for TCP mode) ports.

Both transport modes get a per-container directory under cfg.runtime_dir/run/<container> (mode 0700) that the caller bind-mounts at /run/terok/ inside the container. In TCP mode, two free ports are claimed via bind(0) + getsockname + close so each container gets its own pair instead of fighting over the singleton from cfg.

The narrow window between bind(0)'s close and the supervisor's re-bind on the same port is an EADDRINUSE-loud failure mode, not silent breakage.

Source code in src/terok_sandbox/launch.py
def allocate_per_container_resources(cfg: SandboxConfig, container: str) -> PerContainerResources:
    """Compute per-container paths + (for TCP mode) ports.

    Both transport modes get a per-container directory under
    ``cfg.runtime_dir/run/<container>`` (mode 0700) that the caller
    bind-mounts at ``/run/terok/`` inside the container.  In TCP mode,
    two free ports are claimed via ``bind(0)`` + ``getsockname`` +
    close so each container gets its own pair instead of fighting
    over the singleton from ``cfg``.

    The narrow window between ``bind(0)``'s close and the supervisor's
    re-bind on the same port is an EADDRINUSE-loud failure mode, not
    silent breakage.
    """
    container_runtime_dir = cfg.runtime_dir / "run" / container
    container_runtime_dir.mkdir(parents=True, exist_ok=True)
    container_runtime_dir.chmod(0o700)

    if cfg.services_mode != "tcp":
        return PerContainerResources(
            container_runtime_dir=container_runtime_dir,
            token_broker_port=None,
            ssh_signer_port=None,
            gate_port=None,
        )

    # Allocate all three ports against open sockets *simultaneously* —
    # consecutive ``bind(0)`` + close pairs can legitimately hand back
    # the same port (the kernel is free to reuse the just-freed slot
    # before the next call) and that would crash one of the services on
    # startup with ``EADDRINUSE``.
    broker_port, signer_port, gate_port = _pick_free_tcp_ports(3)
    return PerContainerResources(
        container_runtime_dir=container_runtime_dir,
        token_broker_port=broker_port,
        ssh_signer_port=signer_port,
        gate_port=gate_port,
    )

bridges_resource_dir()

Filesystem path to the bridge resources shipped with this package.

Source code in src/terok_sandbox/launch.py
def bridges_resource_dir() -> Path:
    """Filesystem path to the bridge resources shipped with this package."""
    return Path(__file__).resolve().parent / "resources" / "bridges"

run_state_dir(cfg, container)

Per-container state directory used by prepare/cleanup.

Source code in src/terok_sandbox/launch.py
def run_state_dir(cfg: SandboxConfig, container: str) -> Path:
    """Per-container state directory used by `prepare`/`cleanup`."""
    return cfg.state_dir / "sandbox" / "runs" / container

compose(container, *, cfg, shield, gate, broker, scope, profiles=None)

Compose podman args for one prepare/run invocation.

Mints any tokens needed for the active subsystems (broker/gate/ssh), creates the per-container state directory, persists meta.json, and returns the assembled podman flag list plus the resolved plan.

Subsystems that require scope are silently disabled (with a stderr note) when scope is None — sandbox only enforces the fail-closed property; nudging the caller toward a useful invocation is the job of the CLI layer.

Raises SystemExit if shield setup is required (propagated from ShieldManager.pre_start).

Source code in src/terok_sandbox/launch.py
def compose(
    container: str,
    *,
    cfg: SandboxConfig,
    shield: bool,
    gate: bool,
    broker: bool,
    scope: str | None,
    profiles: tuple[str, ...] | None = None,
) -> tuple[list[str], WiringPlan]:
    """Compose podman args for one prepare/run invocation.

    Mints any tokens needed for the active subsystems (broker/gate/ssh),
    creates the per-container state directory, persists ``meta.json``,
    and returns the assembled podman flag list plus the resolved plan.

    Subsystems that require ``scope`` are silently disabled (with a
    stderr note) when ``scope`` is ``None`` — sandbox only enforces the
    fail-closed property; nudging the caller toward a useful invocation
    is the job of the CLI layer.

    Raises ``SystemExit`` if shield setup is required (propagated from
    [`ShieldManager.pre_start`][terok_sandbox.integrations.shield.ShieldManager.pre_start]).
    """
    _validate_container_name(container)

    from .integrations.shield import ShieldManager

    # Profile override flows through cfg so shield's internal builder
    # (which reads ``cfg.shield_profiles``) picks it up without a new
    # parameter on every layer.  ``__post_init__`` re-runs and skips
    # port re-allocation because every port is already concrete.
    if profiles:
        cfg = dataclasses.replace(cfg, shield_profiles=tuple(profiles))

    state_dir = run_state_dir(cfg, container)
    state_dir.mkdir(parents=True, exist_ok=True)

    # Resolve subsystem activation against the scope precondition.  Three
    # of the four subsystems are scope-bound; silently skipping them with
    # a stderr note when scope is missing keeps the default-on policy
    # honest without forcing users to type --no-broker --no-gate when
    # they just want shield.
    effective_gate = gate and scope is not None
    effective_broker = broker and scope is not None
    effective_ssh = scope is not None

    for name, requested, effective in (
        ("gate", gate, effective_gate),
        ("broker", broker, effective_broker),
    ):
        if requested and not effective:
            print(
                f"note: --{name} requires --scope; skipping (use --no-{name} to silence)",
                file=sys.stderr,
            )

    plan = WiringPlan(
        scope=scope,
        shield=shield,
        gate=effective_gate,
        broker=effective_broker,
        ssh=effective_ssh,
    )

    args: list[str] = []

    # Per-container runtime resources (host-side socket dir + TCP ports).
    # Allocated up front so shield's nft loopback-port allowlist sees
    # the actual broker/signer ports the supervisor will later bind.
    per_container = allocate_per_container_resources(cfg, container)
    loopback_ports = tuple(
        p
        for p in (
            per_container.gate_port,
            per_container.token_broker_port,
            per_container.ssh_signer_port,
        )
        if p is not None
    )

    # Shield first — its OCI hook expects to see the annotations before
    # podman processes any of the other flags.
    if shield:
        args += ShieldManager(
            state_dir, cfg, loopback_ports_override=loopback_ports or None
        ).pre_start(container)

    # User-namespace mapping ensures the host UID matches inside the
    # container, which both the bind-mounted sockets (0600 host-owned)
    # and the shield rules rely on.
    args += podman_userns_args()

    # Bridge resources: bind-mount the package's bridges/ directory so
    # `source /usr/local/share/terok-sandbox/bridges/ensure-bridges.sh`
    # works on any image with socat (and without `COPY`ing the scripts
    # in at build time).  Always emitted — harmless even when the image
    # already has its own copy.
    if plan.needs_bridges():
        args += _volume_args(
            VolumeSpec(
                bridges_resource_dir(),
                CONTAINER_BRIDGES_DIR,
                sharing=Sharing.SHARED,
                read_only=True,
                live=True,
            )
        )

    # Socket-mode: bind-mount the per-container dir at /run/terok/ so
    # the supervisor's later-bound vault.sock / ssh-agent.sock /
    # gate-server.sock appear inside the container at the well-known
    # paths.  The supervisor binds all three inside this directory.
    if cfg.services_mode == "socket" and (effective_broker or effective_ssh or effective_gate):
        args += _volume_args(
            VolumeSpec(
                per_container.container_runtime_dir,
                CONTAINER_RUNTIME_DIR,
                sharing=Sharing.SHARED,
                live=True,
            )
        )

    # Vault token broker env — the in-container bridge script reads the
    # loopback port (socket mode) or the host TCP port (TCP mode) to
    # build its forwarder.
    if effective_broker:
        if cfg.services_mode == "socket":
            args += ["-e", f"TEROK_VAULT_LOOPBACK_PORT={LOOPBACK_VAULT_PORT}"]
        elif per_container.token_broker_port is not None:
            args += ["-e", f"TEROK_TOKEN_BROKER_PORT={per_container.token_broker_port}"]

    # Gate — mint a per-container token; the gate lives in the
    # supervisor, which binds ``gate-server.sock`` inside the already-
    # mounted ``/run/terok/`` dir (socket mode) or a per-container
    # loopback port (TCP mode).  The token travels only via the sidecar
    # + the env var the in-container bridge reads.
    gate_token: str | None = None
    if effective_gate:
        gate_token = mint_gate_token()
        if cfg.services_mode == "socket":
            args += ["-e", f"TEROK_GATE_SOCKET={CONTAINER_RUNTIME_DIR}/gate-server.sock"]
        elif per_container.gate_port is not None:
            args += ["-e", f"TEROK_GATE_PORT={per_container.gate_port}"]
        args += ["-e", f"TEROK_GATE_TOKEN={gate_token}"]

    # SSH signer — mint a phantom token + tell the bridge how to reach
    # the in-supervisor signer.  Socket mode: well-known in-container
    # path (the supervisor binds it inside the per-container dir mount).
    # TCP mode: per-container port from `bind(0)`.
    if effective_ssh:
        # Non-interactive: launch runs under asyncio, where a prompt_toolkit
        # passphrase prompt cannot own the loop.  A locked vault raises
        # NoPassphraseError here — the frontend unlocks before launch.
        db = cfg.open_credential_db(prompt_on_tty=False)
        try:
            ssh_token = db.create_token(scope, container, scope, "ssh")
        finally:
            db.close()
        args += ["-e", f"TEROK_SSH_SIGNER_TOKEN={ssh_token}"]
        if cfg.services_mode == "socket":
            args += ["-e", f"TEROK_SSH_SIGNER_SOCKET={CONTAINER_RUNTIME_DIR}/ssh-agent.sock"]
        elif per_container.ssh_signer_port is not None:
            args += ["-e", f"TEROK_SSH_SIGNER_PORT={per_container.ssh_signer_port}"]

    args += ["--name", container]

    _write_meta(state_dir, plan)
    sidecar_path = _write_sidecar(cfg, container, plan, per_container, gate_token)
    if sidecar_path is None:
        # Fail closed: a launch with no sidecar means the supervisor never
        # starts, so the container would hit dead vault/SSH/gate endpoints.
        # compose() has already minted phantom tokens and laid down the
        # state + runtime dirs, so roll those back before aborting to avoid
        # orphaning them for a container that never started.
        _rollback_compose_state(cfg, container, plan, per_container, state_dir)
        raise SystemExit(
            "sidecar write failed; aborting launch (vault/SSH endpoints would be dead)"
        )
    args += ["--annotation", f"terok.sandbox.sidecar={sidecar_path}"]
    return args, plan

exec_podman(sandbox_args, podman_args)

Replace this process with podman run.

Validates that podman_args (everything the user typed after --) doesn't collide with sandbox-owned flags or volume targets, then os.execvs into podman. Caller doesn't return.

Source code in src/terok_sandbox/launch.py
def exec_podman(sandbox_args: list[str], podman_args: list[str]) -> None:
    """Replace this process with ``podman run``.

    Validates that *podman_args* (everything the user typed after ``--``)
    doesn't collide with sandbox-owned flags or volume targets, then
    ``os.execv``s into podman.  Caller doesn't return.
    """
    if not podman_args:
        raise SystemExit(
            "No image specified. Usage: terok-sandbox run <container> -- <image> [cmd...]"
        )

    reject_managed_flags(podman_args)
    reject_managed_volumes(podman_args)

    podman = _find_podman()
    argv = [podman, "run", *sandbox_args, *podman_args]
    # ``argv`` is fully constructed in-process and uses an absolute path
    # to podman, so shell interpretation and PATH spoofing do not apply.
    os.execv(podman, argv)  # nosec B606

reject_managed_flags(podman_args)

Reject user-supplied flags that sandbox owns.

Mirrors terok-shield's _reject_shield_managed_flags and adds sandbox-specific entries (e.g. --userns).

Source code in src/terok_sandbox/launch.py
def reject_managed_flags(podman_args: list[str]) -> None:
    """Reject user-supplied flags that sandbox owns.

    Mirrors terok-shield's ``_reject_shield_managed_flags`` and adds
    sandbox-specific entries (e.g. ``--userns``).
    """
    conflicts: set[str] = set()
    for arg in podman_args:
        if not arg.startswith("--"):
            continue
        flag = arg.split("=", 1)[0]
        flag = _FLAG_ALIASES.get(flag, flag)
        if flag in SANDBOX_MANAGED_FLAGS:
            conflicts.add(flag)
    if conflicts:
        raise SystemExit(
            f"Flag(s) managed by terok-sandbox, cannot override: {', '.join(sorted(conflicts))}"
        )

reject_managed_volumes(podman_args)

Reject -v host:target whose target overlaps a sandbox mount.

Source code in src/terok_sandbox/launch.py
def reject_managed_volumes(podman_args: list[str]) -> None:
    """Reject ``-v host:target`` whose target overlaps a sandbox mount."""
    conflicts: set[str] = set()
    iterator = iter(podman_args)
    for arg in iterator:
        spec: str | None = None
        if arg == "-v" or arg == "--volume":
            spec = next(iterator, None)
        elif arg.startswith("--volume="):
            spec = arg.split("=", 1)[1]
        if not spec:
            continue
        parts = spec.split(":")
        if len(parts) < 2:
            continue
        target = parts[1]
        # Block exact matches plus any path under ``CONTAINER_RUNTIME_DIR``
        # — sandbox owns that whole subtree, so a deeper user mount would
        # still hide a freshly-bound supervisor socket.
        if target in _MANAGED_VOLUME_TARGETS or target.startswith(f"{CONTAINER_RUNTIME_DIR}/"):
            conflicts.add(target)
    if conflicts:
        raise SystemExit(
            "Volume target(s) managed by terok-sandbox, cannot override: "
            f"{', '.join(sorted(conflicts))}"
        )

cleanup(container, *, cfg)

Reverse a prior prepare/run for container.

Returns True when state was found and torn down, False when there was nothing to clean up. Idempotent — safe to call repeatedly.

Source code in src/terok_sandbox/launch.py
def cleanup(container: str, *, cfg: SandboxConfig) -> bool:
    """Reverse a prior `prepare`/`run` for *container*.

    Returns ``True`` when state was found and torn down, ``False`` when
    there was nothing to clean up.  Idempotent — safe to call repeatedly.
    """
    from .integrations.shield import ShieldManager

    state_dir = run_state_dir(cfg, container)
    plan = _read_meta(state_dir)
    if plan is None:
        return False

    # The gate token lives only in the supervisor process (it never
    # touched disk), so there is nothing to revoke here — the gate stops
    # serving the moment the supervisor dies.
    #
    # Revoke vault tokens before tearing down shield: a still-running
    # container using a revoked token gets clean 401s, not stale 200s.
    if (plan.broker or plan.ssh) and plan.scope is not None:
        from .vault.store.db import (  # noqa: PLC0415
            NoPassphraseError,
            PlaintextDBFoundError,
            WrongPassphraseError,
        )

        try:
            # Non-interactive (teardown runs under asyncio): a locked vault
            # raises rather than prompting — handled below.
            db = cfg.open_credential_db(prompt_on_tty=False)
        except (
            OSError,
            NoPassphraseError,
            PlaintextDBFoundError,
            WrongPassphraseError,
        ) as exc:
            # Cleanup is best-effort: a missing DB (OSError) or a locked
            # / unencrypted / undecryptable vault (the three credential
            # exceptions) all collapse to "already revoked from the
            # caller's point of view" so shield/state teardown can still
            # proceed.  Any other RuntimeError is a real bug — let it
            # propagate.  Warn so
            # the operator knows the broker/SSH phantom tokens for this
            # container are still in the DB and should be cleaned up
            # after the next ``vault unlock``.
            print(
                f"warning: cleanup couldn't revoke broker/SSH tokens for"
                f" {plan.scope}/{container}: {type(exc).__name__}: {exc}\n"
                f"         tokens remain in the credentials DB until the next"
                f" `terok-sandbox credentials revoke` after a `vault unlock`.",
                file=sys.stderr,
            )
            db = None
        if db is not None:
            try:
                db.revoke_tokens(plan.scope, container)
            finally:
                db.close()

    # Shield down is best-effort: when the container has already exited,
    # the OCI poststop hook has already removed the rules.  Resolve the
    # full container UUID via ``podman inspect`` because terok-shield's
    # per-container hub socket is keyed on the ID, not the name; a
    # vanished container surfaces as a non-zero exit and we skip the
    # call (the poststop hook handled it).
    if plan.shield:
        container_id = _resolve_container_id(container)
        if container_id is not None:
            try:
                ShieldManager(state_dir, cfg).down(container, container_id)
            except (SystemExit, OSError):
                pass

    # Sweep the per-container sidecar file and runtime dir.  The OCI
    # poststop hook would normally cover this, but ``terok-sandbox
    # cleanup`` runs out-of-band from podman lifecycle (e.g.
    # ``prepare`` without a corresponding ``podman run``).
    (cfg.state_dir / "sidecar" / f"{container}.json").unlink(missing_ok=True)
    shutil.rmtree(cfg.runtime_dir / "run" / container, ignore_errors=True)

    shutil.rmtree(state_dir, ignore_errors=True)
    return True

format_args(args, *, output_json)

Return the printable form of an args list.

Source code in src/terok_sandbox/launch.py
def format_args(args: list[str], *, output_json: bool) -> str:
    """Return the printable form of an args list."""
    if output_json:
        return json.dumps(args)
    return " ".join(shlex.quote(a) for a in args)