Per-service child runners — one hardened process per supervisor service.
The supervisor used to compose every service (vault proxy, SSH signer,
git gate, clearance hub, verdict server) as coroutines in a single
asyncio loop. That put secret-holding code (the vault's SQLCipher
session key, the signer's private keys) in the same address space as
convenience services (the desktop notifier), so a bug in any of them
exposed all of them.
Each service now runs in its own process, launched by the parent
supervisor via launch_child.
A child does exactly one thing:
harden_self — clear the dumpable flag,
zero the core limit, lock memory — before it opens the credential
store or binds a socket.
- Re-read the sidecar (the parent hands it the same path), rebuild the
one service it owns, and run that service's asyncio loop.
- Await
SIGTERM from the parent, then stop the service and exit 0.
The service classes are constructed and driven the standard way — only
the process boundary is new. IPC is unchanged because every service
already binds a per-container filesystem socket (or loopback port); a
child in a separate process binds the identical path the container
reaches.
The five children map onto the six former services: clearance owns
the hub and the desktop notifier/subscriber (they share the clearance
socket and the notifier only drives the subscriber), while verdict,
vault, signer, and gate are one service each. gate only
runs when the sidecar wired it.
SERVICE_NAMES = _SERVICE_NAMES
module-attribute
run_child(service, container_id, sidecar_path)
Harden, build the one service, run it until SIGTERM; return an exit code.
The synchronous entry the supervise-child CLI verb calls via
asyncio.run. Arms the parent-death signal first (a supervisor
that dies without teardown must never strand a running child), then
loads the sidecar the parent pinned (config, not secrets), hardens
the process before the runner opens the credential store or binds
a socket — honouring the sidecar's debug-mode opt-out — then drives
the single service's lifecycle. A start failure returns
_EXIT_START_FAILED (4) so the parent can log it and carry on,
degrading one service without taking the rest down.
Source code in src/terok_sandbox/supervisor/children.py
| def run_child(service: str, container_id: str, sidecar_path: Path) -> int:
"""Harden, build the one *service*, run it until SIGTERM; return an exit code.
The synchronous entry the ``supervise-child`` CLI verb calls via
``asyncio.run``. Arms the parent-death signal first (a supervisor
that dies without teardown must never strand a running child), then
loads the sidecar the parent pinned (config, not secrets), hardens
the process *before* the runner opens the credential store or binds
a socket — honouring the sidecar's debug-mode opt-out — then drives
the single service's lifecycle. A start failure returns
``_EXIT_START_FAILED`` (4) so the parent can log it and carry on,
degrading one service without taking the rest down.
"""
if not _arm_parent_death_signal():
_logger.error(
"%s child: supervisor died before startup — refusing to run as a stray", service
)
return _EXIT_START_FAILED
runner = _RUNNERS.get(service)
if runner is None:
_logger.error("unknown supervisor child service %r", service)
return _EXIT_BAD_SIDECAR
cfg = load_sidecar(sidecar_path)
if cfg is None:
_logger.error("%s child: no usable sidecar at %s", service, sidecar_path)
return _EXIT_BAD_SIDECAR
report = harden_self(allow_debugger=cfg.allow_debugger)
if not cfg.allow_debugger and not report.fully_hardened:
# Expected in a rootless container (mlockall needs CAP_IPC_LOCK);
# log at debug so the operator can confirm the floor on hosts
# where it should have taken. (Debug mode drops no_dump on
# purpose, so a partial report is not noteworthy there.)
_logger.debug("%s child hardening partial: %s", service, report)
try:
cfg = replace(cfg, _resolved_passphrase=_resolve_service_passphrase(service, cfg))
except Exception:
_logger.exception("%s child passphrase resolution failed", service)
return _EXIT_START_FAILED
paths = SupervisorPaths.for_container(
container_id, cfg.container_name, sidecar_path, cfg.runtime_dir
)
_ensure_socket_dirs(service, paths)
_ensure_policy_dirs(service, cfg)
if service == "gate":
# Read bundled hook sources before confinement hides editable
# dependency trees; only this gate's private runtime lane is written.
from terok_sandbox.gate.hooks import install_hooks
install_hooks(paths.gate_socket.parent / _GATE_HOOKS_DIRNAME)
if not cfg.allow_debugger and service != "verdict":
# Pin filesystem path access to this service's lane. Verdict is
# intentionally exempt: it is the small Podman/nsenter broker, whose
# job requires the operator's container-runtime state. Debug mode
# likewise keeps paths open for dump/trace tools.
#
# This is path isolation, not a same-UID kernel-keyring boundary:
# vault and signer still resolve the shared vault key through the
# existing keyring policy. Process memory has the separate
# ``harden_self`` floor above.
fs = confine_filesystem(
(*_SYSTEM_READABLE_ROOTS, *_readable_paths(service, cfg)),
_writable_paths(service, cfg, paths),
)
if fs.partially_confined:
_logger.warning("%s child filesystem-confinement partial: %s", service, fs.reason)
elif not fs.confined:
_logger.warning("%s child filesystem-confinement not applied: %s", service, fs.reason)
return asyncio.run(_drive(service, runner, cfg, paths))
|