terok_clearance
terok_clearance
¶
Clearance hub + desktop notification library for terok.
The operator-UI plane for terok-shield: turns shield's blocked- connection events into Allow/Deny prompts and routes the operator's verdict back to shield for enforcement. Two axes of pluggability apply:
- Producer (event source) — closed. Shield is the only
producer. The wire vocabulary (
shield_up,connection_blocked, …) names shield's state machine, and the verdict path execsterok-shield allow|deny. A non-shield "clearance" wouldn't work end-to-end; the package is shield's UI plane, not a generic firewall console. - Operator UI (consumer) — open. Anything that subscribes to
the hub's varlink stream and implements the
Notifierprotocol on the verdict-routing side is a valid UI: today the D-Bus desktop notifier (DbusNotifier), the standalone Textualterok clearanceapp, and the embeddedterok-tuiscreen all ride on this seam.
Container-runtime inspection is no longer a clearance concern: the
shield reader resolves the orchestrator-supplied dossier at emit
time and ships it on the wire (ClearanceEvent.dossier), so
clearance has no Python-level coupling to any runtime.
Two unrelated wire formats live under this one package as a result:
org.terok.Clearance1over a unix-socket varlink transport — the hub (ClearanceHub) and the client library (ClearanceClient,EventSubscriber) that drive the per-container block / verdict / lifecycle flow.org.freedesktop.Notificationsover D-Bus — theDbusNotifierwrapper that renders those events as desktop popups. Kept because that's the OS API; the in-package transport is varlink.
The supervisor (in terok-sandbox) composes one
ClearanceHub and one
VerdictServer in-process per
container. Each container has its own hub socket, so operator UIs
multiplex across the per-container sockets via
MultiSocketSubscriber.
__all__ = ['ALL_NOTIFY_CATEGORIES', 'COMMANDS', 'CallbackNotifier', 'ClearanceClient', 'ClearanceEvent', 'ClearanceHub', 'EventSubscriber', 'MultiSocketSubscriber', 'NOTIFY_BLOCKED', 'NOTIFY_VERDICT', 'Notification', 'VerdictClient', 'VerdictServer', 'create_notifier', 'default_clearance_socket_path']
module-attribute
¶
ALL_NOTIFY_CATEGORIES = frozenset({NOTIFY_BLOCKED, NOTIFY_VERDICT, NOTIFY_SHIELD_UP, NOTIFY_SHIELD_DOWN, NOTIFY_CONTAINER_STARTED, NOTIFY_CONTAINER_EXITED})
module-attribute
¶
Every category the subscriber recognises — the default opt-in set.
NOTIFY_BLOCKED = 'blocked'
module-attribute
¶
Category for connection_blocked Allow/Deny prompts (interactive).
NOTIFY_VERDICT = 'verdict'
module-attribute
¶
Category for verdict_applied resolution popups (ack of a verdict).
COMMANDS = (CommandDef(name='notify', help='Send a one-shot desktop notification', source='terok_clearance.cli.verbs.notify:COMMAND'), CommandDef(name='serve', help='Run the clearance hub (serves org.terok.Clearance1 varlink on a unix socket)', source='terok_clearance.cli.verbs.serve:COMMAND'), CommandDef(name='serve-verdict', help='Run the verdict helper (serves org.terok.ClearanceVerdict1 for shield exec)', source='terok_clearance.cli.verbs.serve_verdict:COMMAND'), CommandDef(name='clearance', help='Interactive terminal tool for shield clearance verdicts', source='terok_clearance.cli.verbs.clearance:COMMAND'))
module-attribute
¶
__version__ = '0.0.0'
module-attribute
¶
ClearanceClient(*, socket_path=None)
¶
Thin async client for the Clearance1 varlink service.
Two async coroutines to drive:
start— open the subscribe + RPC connections and begin relaying events to the user-supplied callback. Returns once both channels are live; events arrive viaon_eventfrom then on.verdict— RPC call; returnsTrueifterok-shieldapplied the action,Falseon any refusal or shield failure. The refusal reason is logged at WARNING.
The callback runs on the same event loop as the rest of the client; exceptions it raises are logged and swallowed so one bad handler can't kill the stream for every subsequent event.
Remember the target socket; defaults to default_clearance_socket_path.
Source code in src/terok_clearance/client/client.py
start(on_event)
async
¶
Open both connections and begin relaying events to on_event.
The initial connect is awaited synchronously so callers see
start() return only after the subscription is live — a
hub that's down at startup still propagates as an exception.
Subsequent drops are handled by _run_stream's internal
reconnect loop so long-running consumers (TUI, notifier)
survive a systemctl restart terok-clearance without
restarting themselves.
Source code in src/terok_clearance/client/client.py
stop()
async
¶
Close both connections and await the stream task.
Source code in src/terok_clearance/client/client.py
poke_reconnect()
¶
Skip any in-flight reconnect back-off and retry immediately.
Idempotent; a no-op when the stream is healthy because the
event is only awaited inside _run_stream's back-off
window.
Source code in src/terok_clearance/client/client.py
verdict(container, request_id, dest, action)
async
¶
Apply action (allow / deny) to dest via the hub's Verdict RPC.
Returns True when the hub accepted and applied the verdict,
False for any refusal (unknown request_id, tuple mismatch,
invalid action, shield-exec failure). Callers typically ignore
the return value and let the subsequent verdict_applied
event drive UI updates; refusal reasons are logged at WARNING.
Source code in src/terok_clearance/client/client.py
EventSubscriber(notifier, client=None, *, socket_path=None, enabled_categories=None)
¶
Bridge clearance-hub events into desktop notifications.
Owns the presentation-layer state a rendering client needs: live-block
dedup keyed on (container, target), the tracked ShieldDown
popup per container so ShieldUp can retire it, and verdict routing
through notifier action callbacks.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
notifier
|
Notifier
|
Desktop notification backend (any |
required |
client
|
ClearanceClient | None
|
Pre-configured |
None
|
socket_path
|
Path | None
|
Clearance-socket override when client isn't supplied (tests). |
None
|
enabled_categories
|
Set[str] | None
|
Subset of |
None
|
Initialise the subscriber with a notifier and transport.
Source code in src/terok_clearance/client/subscriber.py
start()
async
¶
Connect to the clearance hub and begin rendering its event stream.
stop()
async
¶
Drain pending tasks and close the transport.
Closes the client first so no new handler tasks are scheduled,
then awaits the currently-tracked tasks to settle (with their
own CancelledError suppressed). A bare sleep(0) would
yield only one loop turn — not enough for cancellation to
propagate through chained awaits — and tasks.clear() on its
own would drop references to tasks still writing to handles we
then close.
Source code in src/terok_clearance/client/subscriber.py
MultiSocketSubscriber(notifier, *, socket_glob=None, enabled_categories=None, rescan_interval_s=2.0)
¶
Subscribe to every per-container clearance hub socket under a glob.
The per-container-supervisor model gives every container its own
hub socket at $XDG_RUNTIME_DIR/terok/clearance/<container_id>.sock,
so an operator UI that wants the union of every supervisor's
event stream must multiplex across the set. This class watches
socket_glob (default
$XDG_RUNTIME_DIR/terok/clearance/*.sock), opens an
EventSubscriber against each
matching path on start,
re-scans every rescan_interval_s seconds to pick up sockets that
appeared after start, and tears down child subscribers whose
sockets vanish. All events fan into the same notifier.
A simple poll loop drives the rescan — no inotify dependency. The
overhead is one glob.glob call per interval, which is cheap
compared to the cost of carrying a fanotify watch fd for what is
effectively a directory that mutates only at container start /
stop.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
notifier
|
Notifier
|
Desktop notification backend (any |
required |
socket_glob
|
str | None
|
Filesystem glob of clearance hub sockets to track.
|
None
|
enabled_categories
|
Set[str] | None
|
Subset of
|
None
|
rescan_interval_s
|
float
|
Seconds between glob rescans. Must be strictly positive. The default (2.0) balances startup latency for a freshly-spawned supervisor against background polling cost when the host is idle. |
2.0
|
Capture notifier + scan parameters; sockets are discovered in start.
Raises:
| Type | Description |
|---|---|
ValueError
|
If rescan_interval_s is not strictly positive.
|
Source code in src/terok_clearance/client/subscriber.py
start()
async
¶
Connect to every currently-matching socket and start the rescan loop.
Sockets that fail to connect at start are logged and skipped — a flaky supervisor doesn't stop the rest from coming online. The rescan loop runs as a background task so newly-spawned supervisors join the merged stream without a manual restart.
Idempotent: a second start() while the rescan loop is still
live is a no-op (the alternative would orphan the existing task
and leak a loop that keeps reconciling).
Source code in src/terok_clearance/client/subscriber.py
stop()
async
¶
Cancel the rescan loop and stop every child subscriber.
Stops are awaited in parallel because each child holds its own transports — serialising would extend shutdown latency by the sum of every disconnect timeout instead of the worst-case one.
Source code in src/terok_clearance/client/subscriber.py
ClearanceEvent(type, container, request_id='', dest='', port=0, proto=0, domain='', action='', ok=False, reason='', dossier=dict())
dataclass
¶
One event fanned out to every Subscribe() caller.
type + container are always populated; the remaining fields
are filled in per-kind and default to zero-values otherwise.
Known values of type (additional fields beyond container):
connection_blocked—request_id,dest,port,proto,domain,dossier. Requires an operator verdict.verdict_applied—request_id,action,ok.container_started—dossier.container_exited—reason,dossier.shield_up/shield_down/shield_disengaged—dossier.
Unknown values are forwarded unchanged so the wire format can grow without breaking clients pinned to older schemas.
type
instance-attribute
¶
container
instance-attribute
¶
request_id = ''
class-attribute
instance-attribute
¶
dest = ''
class-attribute
instance-attribute
¶
port = 0
class-attribute
instance-attribute
¶
proto = 0
class-attribute
instance-attribute
¶
domain = ''
class-attribute
instance-attribute
¶
action = ''
class-attribute
instance-attribute
¶
ok = False
class-attribute
instance-attribute
¶
reason = ''
class-attribute
instance-attribute
¶
dossier = field(default_factory=dict)
class-attribute
instance-attribute
¶
ClearanceHub(*, clearance_socket=None, reader_socket=None, verdict_client=None, socket_context=None)
¶
Server for the org.terok.Clearance1 interface.
Owns three pieces of state:
_subscribers— a set of bounded per-connection queues; the hub puts aClearanceEventon each one every time the reader ingester delivers an event. Slow clients see their oldest events dropped; fast clients aren't affected._live_verdicts— therequest_id → (container, dest)map theVerdictmethod checks for the authz binding.- An
EventIngesterbound to the canonical reader socket.
Lifecycle: start brings everything up; stop tears
it down under individual timeouts so a flaky bus or a stuck
subscriber can't stall the per-container supervisor's teardown.
Configure the two sockets and the verdict-helper client.
verdict_client is injected so tests can stub out shield exec
without spawning the helper process. Production callers leave
it defaulted — a fresh VerdictClient pointing at the
canonical helper socket.
socket_context — optional zero-arg callable returning a context
manager around the varlink bind() (forwarded to
bind_hardened).
terok-sandbox passes its SELinux setsockcreatecon helper here
so the hub socket is labelled terok_socket_t and confined
containers can connectto it.
Source code in src/terok_clearance/hub/server.py
start()
async
¶
Bring the ingester + varlink server online and accept clients.
Transactional: if the varlink bind fails after the ingester is already listening, the ingester is stopped before the exception propagates so a half-started hub doesn't leak a live reader-side socket on systemd restart paths.
Source code in src/terok_clearance/hub/server.py
stop()
async
¶
Close the varlink server + ingester; drain subscriber queues.
Source code in src/terok_clearance/hub/server.py
CallbackNotifier(on_notify=None, *, on_container_started=None, on_container_exited=None, on_shield_up=None, on_shield_down=None, on_shield_disengaged=None)
¶
Notifier backend that delegates rendering to caller-supplied hooks.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
on_notify
|
Callable[[Notification], None] | None
|
Called for every |
None
|
on_container_started
|
Callable[[str], None] | None
|
Called for every |
None
|
on_container_exited
|
Callable[[str, str], None] | None
|
Called for every |
None
|
on_shield_up
|
Callable[[str], None] | None
|
Called for every |
None
|
on_shield_down
|
Callable[[str], None] | None
|
Called for every |
None
|
on_shield_disengaged
|
Callable[[str], None] | None
|
Called for every |
None
|
Bind optional notify and lifecycle callbacks.
Source code in src/terok_clearance/notifications/callback.py
notify(summary, body='', *, actions=(), timeout_ms=-1, hints=None, replaces_id=0, app_icon='', container_id='', container_name='', project='', task_id='', task_name='')
async
¶
Record the notification and invoke the on_notify hook.
Returns a monotonically increasing ID, or replaces_id for updates.
Source code in src/terok_clearance/notifications/callback.py
on_action(notification_id, callback)
async
¶
Store the action callback for later invocation.
close(notification_id)
async
¶
disconnect()
async
¶
invoke_action(notification_id, action_key)
¶
Invoke the stored callback for a user verdict.
This is the entry point for consumers that handle user input
(Allow/Deny) and need to route the decision back through
EventSubscriber to the D-Bus Verdict/Resolve method.
Source code in src/terok_clearance/notifications/callback.py
on_container_started(container)
¶
Forward a ContainerStarted lifecycle event to the consumer hook.
on_container_exited(container, reason)
¶
Forward a ContainerExited lifecycle event to the consumer hook.
on_shield_up(container)
¶
on_shield_down(container)
¶
Forward a ShieldDown signal (partial bypass) to the consumer hook.
on_shield_disengaged(container)
¶
Forward a ShieldDisengaged signal (full bypass) to the consumer hook.
Notification(nid, summary, body, actions, replaces_id, timeout_ms, container_id='', container_name='', project='', task_id='', task_name='')
dataclass
¶
Snapshot of a single notification posted by the subscriber.
The identity fields (container_id, container_name,
project, task_id, task_name) are presentation-layer
context the subscriber's identity_resolver produced — empty
strings when unresolved. The desktop
DbusNotifier
discards all of them; the TUI uses the task triple to render a
Task column for terok-managed containers and falls back to the
container name for standalone ones.
nid
instance-attribute
¶
summary
instance-attribute
¶
body
instance-attribute
¶
actions
instance-attribute
¶
replaces_id
instance-attribute
¶
timeout_ms
instance-attribute
¶
container_id = ''
class-attribute
instance-attribute
¶
container_name = ''
class-attribute
instance-attribute
¶
project = ''
class-attribute
instance-attribute
¶
task_id = ''
class-attribute
instance-attribute
¶
task_name = ''
class-attribute
instance-attribute
¶
VerdictClient(*, socket_path=None)
¶
Call Apply on the verdict helper over its unix varlink socket.
Lazy-connecting: the first apply opens the transport; a
dropped connection reconnects on the next call. Safe under
concurrent verdicts on one instance — asyncvarlink serialises
per-connection replies anyway, and the reconnect lock keeps two
callers from racing into the helper socket together.
Remember the socket; default to default_verdict_socket_path.
Source code in src/terok_clearance/verdict/client.py
apply(container, dest, action)
async
¶
Run one verdict via the helper; return (ok, stderr_snippet).
Returns (False, reason) if the helper is unreachable,
matching the shape callers used to get from the inline shield
exec. Upstream error translation (ShieldCliFailed) still
happens in the hub.
Source code in src/terok_clearance/verdict/client.py
VerdictServer(*, socket_path=None, shield_binary=None, socket_context=None)
¶
Per-process wrapper around the Apply varlink interface.
The hub is the only legitimate client; SO_PEERCRED on the unix
socket rejects peers with a different UID, and
bind_hardened leaves the
socket mode 0600 for the lifetime of the server.
Configure the socket + shield executable path.
socket_context — see
ClearanceHub;
forwarded to
bind_hardened so
the verdict socket can carry a custom SELinux type.
Source code in src/terok_clearance/verdict/server.py
start()
async
¶
Bind the varlink server and start accepting hub verdict calls.
Source code in src/terok_clearance/verdict/server.py
stop()
async
¶
Close the varlink server; existing in-flight Apply calls finish first.
Source code in src/terok_clearance/verdict/server.py
__getattr__(name)
¶
Import and cache a public symbol on first access (PEP 562).
Source code in src/terok_clearance/__init__.py
__dir__()
¶
create_notifier(app_name='terok')
async
¶
Return a connected DbusNotifier, or a NullNotifier on failure.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
app_name
|
str
|
Application name sent with every notification. |
'terok'
|
Returns:
| Type | Description |
|---|---|
Notifier
|
A |
Source code in src/terok_clearance/notifications/factory.py
default_clearance_socket_path()
¶
Return the canonical clearance-socket path under $XDG_RUNTIME_DIR.