alter-runtime 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- alter_runtime/__init__.py +11 -0
- alter_runtime/adapters/__init__.py +19 -0
- alter_runtime/adapters/claude_jsonl_watcher.py +545 -0
- alter_runtime/adapters/git_watcher.py +457 -0
- alter_runtime/adapters/household/__init__.py +29 -0
- alter_runtime/adapters/household/_base.py +138 -0
- alter_runtime/adapters/household/compost/__init__.py +17 -0
- alter_runtime/adapters/household/compost/adapter.py +81 -0
- alter_runtime/adapters/household/compost/storage.py +75 -0
- alter_runtime/adapters/household/compost/tests/__init__.py +0 -0
- alter_runtime/adapters/household/compost/tests/test_adapter.py +62 -0
- alter_runtime/adapters/household/compost/tests/test_storage.py +23 -0
- alter_runtime/adapters/household/compost/tests/test_traits.py +38 -0
- alter_runtime/adapters/household/compost/traits.py +79 -0
- alter_runtime/adapters/household/self_hoster/__init__.py +30 -0
- alter_runtime/adapters/household/self_hoster/adapter.py +248 -0
- alter_runtime/adapters/household/self_hoster/storage.py +83 -0
- alter_runtime/adapters/household/self_hoster/tests/__init__.py +0 -0
- alter_runtime/adapters/household/self_hoster/tests/test_adapter.py +216 -0
- alter_runtime/adapters/household/self_hoster/tests/test_storage.py +25 -0
- alter_runtime/adapters/household/self_hoster/tests/test_traits.py +55 -0
- alter_runtime/adapters/household/self_hoster/traits.py +105 -0
- alter_runtime/adapters/household/tapo_ecosystem/__init__.py +22 -0
- alter_runtime/adapters/household/tapo_ecosystem/adapter.py +98 -0
- alter_runtime/adapters/household/tapo_ecosystem/storage.py +95 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/__init__.py +0 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_adapter.py +55 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_storage.py +28 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_traits.py +45 -0
- alter_runtime/adapters/household/tapo_ecosystem/traits.py +97 -0
- alter_runtime/adapters/household/workshop_tools/__init__.py +25 -0
- alter_runtime/adapters/household/workshop_tools/adapter.py +77 -0
- alter_runtime/adapters/household/workshop_tools/storage.py +92 -0
- alter_runtime/adapters/household/workshop_tools/tests/__init__.py +0 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_adapter.py +48 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_storage.py +26 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_traits.py +45 -0
- alter_runtime/adapters/household/workshop_tools/traits.py +95 -0
- alter_runtime/adapters/worktree_watcher.py +378 -0
- alter_runtime/atlas/__init__.py +48 -0
- alter_runtime/atlas/base.py +102 -0
- alter_runtime/atlas/ledger.py +196 -0
- alter_runtime/atlas/observations.py +136 -0
- alter_runtime/atlas/schema.py +106 -0
- alter_runtime/cap_cache.py +392 -0
- alter_runtime/cli.py +517 -0
- alter_runtime/clients/__init__.py +0 -0
- alter_runtime/clients/token_usage_client.py +273 -0
- alter_runtime/config.py +648 -0
- alter_runtime/consent.py +425 -0
- alter_runtime/daemon.py +518 -0
- alter_runtime/floor_loop.py +335 -0
- alter_runtime/floor_preflight.py +734 -0
- alter_runtime/http_auth.py +173 -0
- alter_runtime/notifiers/__init__.py +18 -0
- alter_runtime/notifiers/desktop.py +321 -0
- alter_runtime/sdk/__init__.py +12 -0
- alter_runtime/sdk/client.py +399 -0
- alter_runtime/service_install.py +616 -0
- alter_runtime/services/__init__.py +59 -0
- alter_runtime/services/launchd/com.alter.runtime.plist.in +90 -0
- alter_runtime/services/systemd/alter-runtime.service.in +74 -0
- alter_runtime/services/systemd/cf-access-env.conf.in +29 -0
- alter_runtime/sockets/__init__.py +20 -0
- alter_runtime/sockets/dbus.py +272 -0
- alter_runtime/sockets/unix.py +702 -0
- alter_runtime/subscribers/__init__.py +58 -0
- alter_runtime/subscribers/active_sessions_cron_emitter.py +313 -0
- alter_runtime/subscribers/active_sessions_do_publisher.py +1159 -0
- alter_runtime/subscribers/active_sessions_gc.py +432 -0
- alter_runtime/subscribers/active_sessions_writer.py +446 -0
- alter_runtime/subscribers/adapters_writer.py +415 -0
- alter_runtime/subscribers/agent_frames.py +461 -0
- alter_runtime/subscribers/bus.py +188 -0
- alter_runtime/subscribers/cache_writer.py +347 -0
- alter_runtime/subscribers/ceremony_echo.py +290 -0
- alter_runtime/subscribers/do_sse.py +864 -0
- alter_runtime/subscribers/ebpf.py +506 -0
- alter_runtime/subscribers/inbox_writer.py +469 -0
- alter_runtime/subscribers/mcp_fallback.py +391 -0
- alter_runtime/subscribers/presence_writer.py +426 -0
- alter_runtime/subscribers/session_presence.py +467 -0
- alter_runtime/subscribers/sse.py +125 -0
- alter_runtime/subscribers/weave_intent_writer.py +608 -0
- alter_runtime/update_loop.py +519 -0
- alter_runtime/weave/__init__.py +21 -0
- alter_runtime/weave/resolver.py +544 -0
- alter_runtime-0.3.0.dist-info/METADATA +289 -0
- alter_runtime-0.3.0.dist-info/RECORD +92 -0
- alter_runtime-0.3.0.dist-info/WHEEL +4 -0
- alter_runtime-0.3.0.dist-info/entry_points.txt +2 -0
- alter_runtime-0.3.0.dist-info/licenses/LICENSE +190 -0
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
"""EbpfSubscriber - wires the eBPF kernel attestation loader into the daemon (Wave 5b).
|
|
2
|
+
|
|
3
|
+
The Rust ``alter-ebpf`` binary (subcrate ``packages/alter-ebpf``) is the
|
|
4
|
+
userspace half of a BPF LSM program that observes ``bprm_check_security``
|
|
5
|
+
and emits one ``alter1``-shaped JSON envelope per line to stdout for every
|
|
6
|
+
exec on a uid the operator opted in to. Patent M (AMCZ-2615626261, FILED
|
|
7
|
+
7 Apr 2026) covers this kernel-adjacent attestation surface.
|
|
8
|
+
|
|
9
|
+
This subscriber is the bridge between that binary and the in-process
|
|
10
|
+
:class:`EventBus`. It:
|
|
11
|
+
|
|
12
|
+
1. Resolves the binary path (``ALTER_EBPF_BIN`` env var, then
|
|
13
|
+
:func:`shutil.which`, then :data:`DEFAULT_BINARY_NAMES`).
|
|
14
|
+
2. Resolves the target uid (``ALTER_EBPF_TARGET_UID`` env var, then the
|
|
15
|
+
effective uid of the daemon process). The privacy filter inside the
|
|
16
|
+
BPF program drops every event whose uid does not match this value, so
|
|
17
|
+
the daemon never sees another user's exec stream even if it were
|
|
18
|
+
started as root.
|
|
19
|
+
3. Spawns the binary as an asyncio subprocess (``alter-ebpf load
|
|
20
|
+
--target-uid <uid>``).
|
|
21
|
+
4. Reads stdout line-by-line, parses each line as the ``alter1`` envelope
|
|
22
|
+
the Rust loader produces, and publishes:
|
|
23
|
+
|
|
24
|
+
* :data:`TOPIC_KERNEL_EXEC` - the ``payload`` dict from the envelope,
|
|
25
|
+
so subscribers that only care about kernel exec events can listen
|
|
26
|
+
on a specific topic without filtering.
|
|
27
|
+
* :data:`TOPIC_LOCAL_SIGNAL` - the full envelope, mirroring the
|
|
28
|
+
convention established by ``git_watcher`` and other local adapters.
|
|
29
|
+
The egress producer (Wave 6) will relay these to the DO.
|
|
30
|
+
5. Treats stderr as plain log output and reroutes it through the
|
|
31
|
+
subscriber logger at INFO level. The Rust binary writes
|
|
32
|
+
``tracing-subscriber`` output to stderr; we don't try to parse it.
|
|
33
|
+
6. On any unclean exit (non-zero status, transient I/O error,
|
|
34
|
+
subprocess crash) backs off and restarts the loader. The supervisor
|
|
35
|
+
provides the outer safety net for truly unexpected failures, but the
|
|
36
|
+
inner backoff loop is what survives a kernel verifier rejecting a
|
|
37
|
+
newly hot-reloaded program.
|
|
38
|
+
|
|
39
|
+
The subscriber gracefully **disables itself** if:
|
|
40
|
+
|
|
41
|
+
* the ``alter-ebpf`` binary is not on PATH (and ``ALTER_EBPF_BIN`` is not
|
|
42
|
+
set);
|
|
43
|
+
* the binary is found but the platform is not Linux;
|
|
44
|
+
* the loader exits non-zero on its first attach attempt with the same
|
|
45
|
+
error twice in a row (typically: kernel lacks BPF LSM in the active
|
|
46
|
+
stack - there is no point retrying).
|
|
47
|
+
|
|
48
|
+
Disabling logs once at WARNING and the :meth:`run` task exits cleanly so
|
|
49
|
+
the supervisor does not restart it. This is the right behaviour for an
|
|
50
|
+
opt-in, kernel-version-sensitive surface - we should not flood the
|
|
51
|
+
journal on hosts that simply don't have BPF LSM enabled.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
from __future__ import annotations
|
|
55
|
+
|
|
56
|
+
import asyncio
|
|
57
|
+
import json
|
|
58
|
+
import logging
|
|
59
|
+
import os
|
|
60
|
+
import shutil
|
|
61
|
+
from dataclasses import dataclass, field
|
|
62
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
63
|
+
|
|
64
|
+
from alter_runtime.daemon import Component
|
|
65
|
+
|
|
66
|
+
if TYPE_CHECKING:
|
|
67
|
+
from collections.abc import Awaitable
|
|
68
|
+
|
|
69
|
+
from alter_runtime.config import DaemonConfig
|
|
70
|
+
from alter_runtime.subscribers.bus import EventBus
|
|
71
|
+
|
|
72
|
+
__all__ = [
|
|
73
|
+
"DEFAULT_BINARY_NAMES",
|
|
74
|
+
"EbpfSubscriber",
|
|
75
|
+
"TOPIC_KERNEL_EXEC",
|
|
76
|
+
"TOPIC_LOCAL_SIGNAL",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
logger = logging.getLogger("alter_runtime.subscribers.ebpf")
|
|
80
|
+
|
|
81
|
+
#: Topic published with the parsed payload dict from each kernel exec event.
|
|
82
|
+
TOPIC_KERNEL_EXEC: str = "kernel.attest.exec"
|
|
83
|
+
|
|
84
|
+
#: Topic published with the full ``alter1`` envelope, matching the convention
|
|
85
|
+
#: used by other local adapters that emit ambient signals destined for DO
|
|
86
|
+
#: relay (Wave 6).
|
|
87
|
+
TOPIC_LOCAL_SIGNAL: str = "local.signal"
|
|
88
|
+
|
|
89
|
+
#: Names that resolve via :func:`shutil.which` if no explicit binary path
|
|
90
|
+
#: is configured. The first one to resolve wins.
|
|
91
|
+
DEFAULT_BINARY_NAMES: tuple[str, ...] = ("alter-ebpf",)
|
|
92
|
+
|
|
93
|
+
#: Initial back-off in seconds after a transient subprocess failure.
|
|
94
|
+
BASE_BACKOFF_SECONDS: float = 1.0
|
|
95
|
+
|
|
96
|
+
#: Upper bound on exponential back-off between subprocess restarts.
|
|
97
|
+
MAX_BACKOFF_SECONDS: float = 60.0
|
|
98
|
+
|
|
99
|
+
#: Maximum line length we will buffer from stdout. The Rust loader emits one
|
|
100
|
+
#: JSON object per line; the largest legitimate line is bounded by the
|
|
101
|
+
#: ``filename`` field (256 bytes) plus envelope overhead. We cap at 16 KiB
|
|
102
|
+
#: to absorb future fields without giving an attacker a way to OOM the
|
|
103
|
+
#: daemon if a malicious binary on PATH ever masqueraded as alter-ebpf.
|
|
104
|
+
MAX_LINE_BYTES: int = 16 * 1024
|
|
105
|
+
|
|
106
|
+
#: A factory that returns a started ``asyncio.subprocess.Process``. Tests
|
|
107
|
+
#: pass a fake here to avoid actually spawning the Rust binary.
|
|
108
|
+
ProcessFactory = Callable[[list[str]], "Awaitable[asyncio.subprocess.Process]"]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class _SubscriberState:
|
|
113
|
+
"""Internal book-keeping (exposed via :attr:`EbpfSubscriber.state` for tests)."""
|
|
114
|
+
|
|
115
|
+
spawn_count: int = 0
|
|
116
|
+
line_count: int = 0
|
|
117
|
+
parse_error_count: int = 0
|
|
118
|
+
publish_count: int = 0
|
|
119
|
+
backoff: float = BASE_BACKOFF_SECONDS
|
|
120
|
+
disabled: bool = False
|
|
121
|
+
history: list[str] = field(default_factory=list)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class EbpfSubscriber(Component):
|
|
125
|
+
"""Spawns the eBPF loader and republishes its event stream onto the bus.
|
|
126
|
+
|
|
127
|
+
Parameters
|
|
128
|
+
----------
|
|
129
|
+
config:
|
|
130
|
+
Loaded :class:`DaemonConfig`. Currently unused but accepted so the
|
|
131
|
+
subscriber matches the calling convention of every other Component
|
|
132
|
+
in the package and so future config knobs (poll interval, ringbuf
|
|
133
|
+
sizing) can be added without breaking callers.
|
|
134
|
+
bus:
|
|
135
|
+
The shared :class:`EventBus` instance.
|
|
136
|
+
binary_path:
|
|
137
|
+
Optional explicit path to the ``alter-ebpf`` binary. If ``None``,
|
|
138
|
+
the subscriber consults ``ALTER_EBPF_BIN`` and then
|
|
139
|
+
:func:`shutil.which`. The Path equivalent works too.
|
|
140
|
+
target_uid:
|
|
141
|
+
Optional uid to observe. Defaults to ``ALTER_EBPF_TARGET_UID`` then
|
|
142
|
+
the daemon's effective uid.
|
|
143
|
+
process_factory:
|
|
144
|
+
Optional factory that returns an ``asyncio.subprocess.Process``.
|
|
145
|
+
Tests inject a fake to drive the subscriber without exec-ing the
|
|
146
|
+
real Rust binary. Production callers leave this ``None`` and the
|
|
147
|
+
subscriber uses :func:`asyncio.create_subprocess_exec`.
|
|
148
|
+
extra_args:
|
|
149
|
+
Optional extra CLI arguments appended after ``load --target-uid
|
|
150
|
+
<uid>``. Useful for future flags (e.g. ``--ringbuf-pages``) and
|
|
151
|
+
for tests that want to confirm pass-through.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
name = "ebpf"
|
|
155
|
+
|
|
156
|
+
def __init__(
|
|
157
|
+
self,
|
|
158
|
+
config: DaemonConfig,
|
|
159
|
+
bus: EventBus,
|
|
160
|
+
*,
|
|
161
|
+
binary_path: str | os.PathLike[str] | None = None,
|
|
162
|
+
target_uid: int | None = None,
|
|
163
|
+
process_factory: ProcessFactory | None = None,
|
|
164
|
+
extra_args: list[str] | None = None,
|
|
165
|
+
) -> None:
|
|
166
|
+
self._config = config
|
|
167
|
+
self._bus = bus
|
|
168
|
+
self._binary_path = self._resolve_binary(binary_path)
|
|
169
|
+
self._target_uid = self._resolve_target_uid(target_uid)
|
|
170
|
+
self._process_factory: ProcessFactory = (
|
|
171
|
+
process_factory if process_factory is not None else _default_process_factory
|
|
172
|
+
)
|
|
173
|
+
self._extra_args = list(extra_args or [])
|
|
174
|
+
self._stop_event = asyncio.Event()
|
|
175
|
+
self._proc: asyncio.subprocess.Process | None = None
|
|
176
|
+
self._state = _SubscriberState()
|
|
177
|
+
|
|
178
|
+
# ------------------------------------------------------------------
|
|
179
|
+
# Component lifecycle
|
|
180
|
+
# ------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
async def run(self) -> None:
|
|
183
|
+
"""Long-lived loop: spawn → drain stdout → restart on failure."""
|
|
184
|
+
if self._binary_path is None:
|
|
185
|
+
logger.warning(
|
|
186
|
+
"ebpf subscriber disabled - alter-ebpf binary not found "
|
|
187
|
+
"(set ALTER_EBPF_BIN or install via packages/alter-ebpf). "
|
|
188
|
+
"Kernel attestation will not be available on this host."
|
|
189
|
+
)
|
|
190
|
+
self._state.disabled = True
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
argv = [
|
|
194
|
+
str(self._binary_path),
|
|
195
|
+
"load",
|
|
196
|
+
"--target-uid",
|
|
197
|
+
str(self._target_uid),
|
|
198
|
+
*self._extra_args,
|
|
199
|
+
]
|
|
200
|
+
logger.info(
|
|
201
|
+
"ebpf starting binary=%s target_uid=%d",
|
|
202
|
+
self._binary_path,
|
|
203
|
+
self._target_uid,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
consecutive_immediate_failures = 0
|
|
207
|
+
while not self._stop_event.is_set():
|
|
208
|
+
try:
|
|
209
|
+
exit_code = await self._run_one_session(argv)
|
|
210
|
+
except asyncio.CancelledError:
|
|
211
|
+
raise
|
|
212
|
+
except FileNotFoundError as exc:
|
|
213
|
+
# The binary disappeared between resolution and spawn -
|
|
214
|
+
# treat exactly as the resolution-failure case above.
|
|
215
|
+
logger.warning(
|
|
216
|
+
"ebpf binary vanished from %s (%s) - disabling",
|
|
217
|
+
self._binary_path,
|
|
218
|
+
exc,
|
|
219
|
+
)
|
|
220
|
+
self._state.disabled = True
|
|
221
|
+
return
|
|
222
|
+
except Exception as exc:
|
|
223
|
+
logger.exception("ebpf unexpected error spawning loader: %s", exc)
|
|
224
|
+
exit_code = -1
|
|
225
|
+
|
|
226
|
+
if self._stop_event.is_set():
|
|
227
|
+
break
|
|
228
|
+
|
|
229
|
+
# If the loader keeps exiting immediately (within < base backoff)
|
|
230
|
+
# twice in a row, the kernel is almost certainly missing BPF LSM
|
|
231
|
+
# support and we should disable rather than spinning the journal.
|
|
232
|
+
if exit_code != 0:
|
|
233
|
+
consecutive_immediate_failures += 1
|
|
234
|
+
if consecutive_immediate_failures >= 2:
|
|
235
|
+
logger.warning(
|
|
236
|
+
"ebpf loader exited non-zero twice in a row "
|
|
237
|
+
"(last exit=%d) - disabling. Common causes: bpf LSM "
|
|
238
|
+
"not in /sys/kernel/security/lsm; daemon lacks "
|
|
239
|
+
"CAP_BPF + CAP_PERFMON + CAP_SYS_ADMIN; kernel BTF "
|
|
240
|
+
"missing.",
|
|
241
|
+
exit_code,
|
|
242
|
+
)
|
|
243
|
+
self._state.disabled = True
|
|
244
|
+
return
|
|
245
|
+
else:
|
|
246
|
+
consecutive_immediate_failures = 0
|
|
247
|
+
|
|
248
|
+
await self._backoff_then_retry()
|
|
249
|
+
|
|
250
|
+
logger.info("ebpf stopped binary=%s", self._binary_path)
|
|
251
|
+
|
|
252
|
+
async def stop(self) -> None:
|
|
253
|
+
"""Cooperative shutdown - kill the subprocess and release the loop."""
|
|
254
|
+
self._stop_event.set()
|
|
255
|
+
proc = self._proc
|
|
256
|
+
if proc is not None and proc.returncode is None:
|
|
257
|
+
try:
|
|
258
|
+
proc.terminate()
|
|
259
|
+
except ProcessLookupError:
|
|
260
|
+
pass
|
|
261
|
+
|
|
262
|
+
# ------------------------------------------------------------------
|
|
263
|
+
# Subprocess session
|
|
264
|
+
# ------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
async def _run_one_session(self, argv: list[str]) -> int:
|
|
267
|
+
"""Spawn one loader instance and drain it until it exits.
|
|
268
|
+
|
|
269
|
+
Returns the subprocess exit code (or ``-1`` for an internal error).
|
|
270
|
+
"""
|
|
271
|
+
self._state.spawn_count += 1
|
|
272
|
+
self._state.history.append(f"spawn:{self._state.spawn_count}")
|
|
273
|
+
proc = await self._process_factory(argv)
|
|
274
|
+
self._proc = proc
|
|
275
|
+
try:
|
|
276
|
+
stdout_task = asyncio.create_task(self._drain_stdout(proc))
|
|
277
|
+
stderr_task = asyncio.create_task(self._drain_stderr(proc))
|
|
278
|
+
stop_waiter = asyncio.create_task(self._stop_event.wait())
|
|
279
|
+
|
|
280
|
+
done, pending = await asyncio.wait(
|
|
281
|
+
{stdout_task, stop_waiter},
|
|
282
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
if stop_waiter in done:
|
|
286
|
+
# Caller asked us to stop. Terminate the loader and let
|
|
287
|
+
# the drain tasks finish naturally on EOF.
|
|
288
|
+
if proc.returncode is None:
|
|
289
|
+
try:
|
|
290
|
+
proc.terminate()
|
|
291
|
+
except ProcessLookupError:
|
|
292
|
+
pass
|
|
293
|
+
else:
|
|
294
|
+
stop_waiter.cancel()
|
|
295
|
+
|
|
296
|
+
# Always finish draining whatever's still buffered. Bound the
|
|
297
|
+
# wait so a wedged subprocess can't hang shutdown indefinitely.
|
|
298
|
+
for task in (stdout_task, stderr_task):
|
|
299
|
+
if not task.done():
|
|
300
|
+
try:
|
|
301
|
+
await asyncio.wait_for(task, timeout=5.0)
|
|
302
|
+
except (TimeoutError, asyncio.TimeoutError):
|
|
303
|
+
task.cancel()
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
exit_code = await asyncio.wait_for(proc.wait(), timeout=5.0)
|
|
307
|
+
except (TimeoutError, asyncio.TimeoutError):
|
|
308
|
+
logger.warning("ebpf loader did not exit after terminate; killing")
|
|
309
|
+
try:
|
|
310
|
+
proc.kill()
|
|
311
|
+
except ProcessLookupError:
|
|
312
|
+
pass
|
|
313
|
+
exit_code = await proc.wait()
|
|
314
|
+
|
|
315
|
+
logger.info(
|
|
316
|
+
"ebpf loader session ended exit=%d lines=%d publishes=%d parse_errors=%d",
|
|
317
|
+
exit_code,
|
|
318
|
+
self._state.line_count,
|
|
319
|
+
self._state.publish_count,
|
|
320
|
+
self._state.parse_error_count,
|
|
321
|
+
)
|
|
322
|
+
return exit_code
|
|
323
|
+
finally:
|
|
324
|
+
self._proc = None
|
|
325
|
+
|
|
326
|
+
# ------------------------------------------------------------------
|
|
327
|
+
# Drain helpers
|
|
328
|
+
# ------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
async def _drain_stdout(self, proc: asyncio.subprocess.Process) -> None:
|
|
331
|
+
"""Read newline-delimited JSON envelopes and publish to the bus."""
|
|
332
|
+
stdout = proc.stdout
|
|
333
|
+
if stdout is None:
|
|
334
|
+
logger.warning("ebpf process has no stdout pipe")
|
|
335
|
+
return
|
|
336
|
+
while True:
|
|
337
|
+
try:
|
|
338
|
+
line = await stdout.readline()
|
|
339
|
+
except asyncio.LimitOverrunError as exc:
|
|
340
|
+
# asyncio.StreamReader.readline raises this if a line
|
|
341
|
+
# exceeds the buffer limit. Discard up to the next newline
|
|
342
|
+
# and keep going.
|
|
343
|
+
logger.warning(
|
|
344
|
+
"ebpf line longer than buffer (%d bytes consumed) - discarding",
|
|
345
|
+
exc.consumed,
|
|
346
|
+
)
|
|
347
|
+
await stdout.read(exc.consumed)
|
|
348
|
+
self._state.parse_error_count += 1
|
|
349
|
+
continue
|
|
350
|
+
if not line:
|
|
351
|
+
return # EOF
|
|
352
|
+
self._state.line_count += 1
|
|
353
|
+
await self._handle_line(line)
|
|
354
|
+
|
|
355
|
+
async def _drain_stderr(self, proc: asyncio.subprocess.Process) -> None:
|
|
356
|
+
"""Reroute the loader's stderr through our logger at INFO level."""
|
|
357
|
+
stderr = proc.stderr
|
|
358
|
+
if stderr is None:
|
|
359
|
+
return
|
|
360
|
+
while True:
|
|
361
|
+
line = await stderr.readline()
|
|
362
|
+
if not line:
|
|
363
|
+
return
|
|
364
|
+
text = line.decode("utf-8", errors="replace").rstrip()
|
|
365
|
+
if text:
|
|
366
|
+
logger.info("ebpf-loader: %s", text)
|
|
367
|
+
|
|
368
|
+
async def _handle_line(self, raw: bytes) -> None:
|
|
369
|
+
"""Parse one stdout line and publish on the bus.
|
|
370
|
+
|
|
371
|
+
Bad JSON or unexpected envelope shapes are counted and dropped -
|
|
372
|
+
a single malformed line must never crash the dispatcher.
|
|
373
|
+
"""
|
|
374
|
+
if len(raw) > MAX_LINE_BYTES:
|
|
375
|
+
logger.warning("ebpf line %d bytes > MAX_LINE_BYTES - dropping", len(raw))
|
|
376
|
+
self._state.parse_error_count += 1
|
|
377
|
+
return
|
|
378
|
+
try:
|
|
379
|
+
envelope: Any = json.loads(raw)
|
|
380
|
+
except json.JSONDecodeError as exc:
|
|
381
|
+
logger.debug("ebpf line not JSON: %s (%r)", exc, raw[:120])
|
|
382
|
+
self._state.parse_error_count += 1
|
|
383
|
+
return
|
|
384
|
+
if not isinstance(envelope, dict):
|
|
385
|
+
logger.debug("ebpf line not a JSON object: %r", raw[:120])
|
|
386
|
+
self._state.parse_error_count += 1
|
|
387
|
+
return
|
|
388
|
+
if envelope.get("v") != "alter1":
|
|
389
|
+
logger.debug("ebpf line missing alter1 envelope: %r", envelope.get("v"))
|
|
390
|
+
self._state.parse_error_count += 1
|
|
391
|
+
return
|
|
392
|
+
|
|
393
|
+
kind = envelope.get("kind")
|
|
394
|
+
payload = envelope.get("payload")
|
|
395
|
+
if not isinstance(kind, str) or not isinstance(payload, dict):
|
|
396
|
+
logger.debug("ebpf envelope missing kind/payload: %r", envelope)
|
|
397
|
+
self._state.parse_error_count += 1
|
|
398
|
+
return
|
|
399
|
+
|
|
400
|
+
# Specific topic for kernel-exec consumers.
|
|
401
|
+
if kind == "kernel.attest.exec":
|
|
402
|
+
await self._bus.publish(TOPIC_KERNEL_EXEC, payload)
|
|
403
|
+
|
|
404
|
+
# Generic local-signal topic for the egress relay (Wave 6) and any
|
|
405
|
+
# other subscriber that wants every ambient signal regardless of
|
|
406
|
+
# source. Mirrors what git_watcher publishes.
|
|
407
|
+
await self._bus.publish(TOPIC_LOCAL_SIGNAL, envelope)
|
|
408
|
+
self._state.publish_count += 1
|
|
409
|
+
|
|
410
|
+
# ------------------------------------------------------------------
|
|
411
|
+
# Resolution helpers
|
|
412
|
+
# ------------------------------------------------------------------
|
|
413
|
+
|
|
414
|
+
def _resolve_binary(self, explicit: str | os.PathLike[str] | None) -> str | None:
|
|
415
|
+
"""Find the alter-ebpf binary, returning ``None`` if unavailable."""
|
|
416
|
+
if explicit is not None:
|
|
417
|
+
path = os.fspath(explicit)
|
|
418
|
+
if os.path.isfile(path) and os.access(path, os.X_OK):
|
|
419
|
+
return path
|
|
420
|
+
logger.warning(
|
|
421
|
+
"ebpf binary path %r is not an executable file - falling back to PATH",
|
|
422
|
+
path,
|
|
423
|
+
)
|
|
424
|
+
env = os.environ.get("ALTER_EBPF_BIN")
|
|
425
|
+
if env:
|
|
426
|
+
if os.path.isfile(env) and os.access(env, os.X_OK):
|
|
427
|
+
return env
|
|
428
|
+
logger.warning(
|
|
429
|
+
"ALTER_EBPF_BIN=%r is not an executable file - falling back to PATH",
|
|
430
|
+
env,
|
|
431
|
+
)
|
|
432
|
+
for name in DEFAULT_BINARY_NAMES:
|
|
433
|
+
resolved = shutil.which(name)
|
|
434
|
+
if resolved:
|
|
435
|
+
return resolved
|
|
436
|
+
return None
|
|
437
|
+
|
|
438
|
+
def _resolve_target_uid(self, explicit: int | None) -> int:
|
|
439
|
+
"""Decide which uid to observe."""
|
|
440
|
+
if explicit is not None:
|
|
441
|
+
return explicit
|
|
442
|
+
env = os.environ.get("ALTER_EBPF_TARGET_UID")
|
|
443
|
+
if env:
|
|
444
|
+
try:
|
|
445
|
+
return int(env)
|
|
446
|
+
except ValueError:
|
|
447
|
+
logger.warning(
|
|
448
|
+
"ALTER_EBPF_TARGET_UID=%r is not an integer - using effective uid",
|
|
449
|
+
env,
|
|
450
|
+
)
|
|
451
|
+
try:
|
|
452
|
+
return os.geteuid()
|
|
453
|
+
except AttributeError:
|
|
454
|
+
# Non-POSIX (Windows) - uid concept doesn't apply but the
|
|
455
|
+
# subscriber is Linux-only in practice. Return 0 as a placeholder
|
|
456
|
+
# so we still spawn the binary in tests on non-POSIX hosts.
|
|
457
|
+
return 0
|
|
458
|
+
|
|
459
|
+
# ------------------------------------------------------------------
|
|
460
|
+
# Backoff
|
|
461
|
+
# ------------------------------------------------------------------
|
|
462
|
+
|
|
463
|
+
async def _backoff_then_retry(self) -> None:
|
|
464
|
+
"""Sleep for the current backoff, then double it for next time."""
|
|
465
|
+
delay = self._state.backoff
|
|
466
|
+
self._state.backoff = min(delay * 2, MAX_BACKOFF_SECONDS)
|
|
467
|
+
logger.info("ebpf restarting loader in %.1fs", delay)
|
|
468
|
+
try:
|
|
469
|
+
await asyncio.wait_for(self._stop_event.wait(), timeout=delay)
|
|
470
|
+
except (TimeoutError, asyncio.TimeoutError):
|
|
471
|
+
return
|
|
472
|
+
|
|
473
|
+
# ------------------------------------------------------------------
|
|
474
|
+
# Test introspection
|
|
475
|
+
# ------------------------------------------------------------------
|
|
476
|
+
|
|
477
|
+
@property
|
|
478
|
+
def state(self) -> _SubscriberState:
|
|
479
|
+
"""Internal state snapshot (used by tests)."""
|
|
480
|
+
return self._state
|
|
481
|
+
|
|
482
|
+
@property
|
|
483
|
+
def binary_path(self) -> str | None:
|
|
484
|
+
"""The resolved binary path, or ``None`` if the subscriber is disabled."""
|
|
485
|
+
return self._binary_path
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
# ---------------------------------------------------------------------------
|
|
489
|
+
# Default process factory
|
|
490
|
+
# ---------------------------------------------------------------------------
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
async def _default_process_factory(argv: list[str]) -> asyncio.subprocess.Process:
|
|
494
|
+
"""Spawn the loader via :func:`asyncio.create_subprocess_exec`."""
|
|
495
|
+
return await asyncio.create_subprocess_exec(
|
|
496
|
+
*argv,
|
|
497
|
+
stdout=asyncio.subprocess.PIPE,
|
|
498
|
+
stderr=asyncio.subprocess.PIPE,
|
|
499
|
+
# The loader must not inherit our stdin - it doesn't read it and
|
|
500
|
+
# connecting to a tty can cause confusing behaviour under systemd.
|
|
501
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
502
|
+
# Run in its own process group so a Ctrl-C delivered to the daemon
|
|
503
|
+
# supervisor's pgid doesn't double-signal the loader before our
|
|
504
|
+
# cooperative terminate() lands.
|
|
505
|
+
start_new_session=True,
|
|
506
|
+
)
|