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,702 @@
|
|
|
1
|
+
"""UnixSocketServer - local JSON-RPC surface for the runtime daemon.
|
|
2
|
+
|
|
3
|
+
Binds an ``asyncio`` Unix-domain socket at :attr:`DaemonConfig.unix_socket`
|
|
4
|
+
(typically ``$XDG_RUNTIME_DIR/alter.sock`` on Linux) with mode ``0o600`` and
|
|
5
|
+
speaks line-delimited JSON to connected clients.
|
|
6
|
+
|
|
7
|
+
Wire protocol
|
|
8
|
+
-------------
|
|
9
|
+
|
|
10
|
+
Every message (request or response) is a single UTF-8 line terminated with
|
|
11
|
+
``\\n``. Requests are JSON-RPC 2.0-ish - we keep it minimal:
|
|
12
|
+
|
|
13
|
+
::
|
|
14
|
+
|
|
15
|
+
{"method": "ping"} -> {"ok": true, "pong": true}
|
|
16
|
+
{"method": "auth", "token": "<t>"} -> {"ok": true, "authenticated": true}
|
|
17
|
+
{"method": "subscribe"} -> streams live events as they arrive
|
|
18
|
+
{"method": "ingest", "kind": "vault_consent_grant", "payload": {...}}
|
|
19
|
+
{"method": "whoami"} -> {"ok": true, "handle": "~foo"}
|
|
20
|
+
{"method": "agent/roster"} -> {"ok": true, "roster": [...instruments...]}
|
|
21
|
+
|
|
22
|
+
Streaming events (from a live ``subscribe``) are pushed as server-originated
|
|
23
|
+
frames of the form::
|
|
24
|
+
|
|
25
|
+
{"event": "identity.frame", "data": {...SSEFrame fields...}}
|
|
26
|
+
|
|
27
|
+
Closing the client socket ends the subscription. Disconnects during write
|
|
28
|
+
are swallowed.
|
|
29
|
+
|
|
30
|
+
Security
|
|
31
|
+
--------
|
|
32
|
+
|
|
33
|
+
The socket is created with the process umask tightened to ``0o077`` and then
|
|
34
|
+
``os.chmod()``'d to ``0o600`` to keep every other UID off it. POSIX peer-cred
|
|
35
|
+
inspection is not required for cross-UID isolation - the ``0o600`` mode alone
|
|
36
|
+
is sufficient on Linux and macOS because neither permits other users to open
|
|
37
|
+
a socket they cannot read/write. Windows uses a Named Pipe instead (Wave 3).
|
|
38
|
+
|
|
39
|
+
Beyond the cross-UID gate, the server requires a token-based auth handshake
|
|
40
|
+
*within* the same UID - every method except ``ping`` is refused until the
|
|
41
|
+
client has presented the daemon's startup token. The token is minted at
|
|
42
|
+
``run()`` and written to ``<socket_parent>/alter-daemon-token`` mode ``0o600``;
|
|
43
|
+
same-UID clients read the file and present the value as
|
|
44
|
+
``{"method":"auth","token":"<t>"}`` immediately after connect. Auth failures
|
|
45
|
+
close the connection. This narrows the attack surface from "any same-UID
|
|
46
|
+
process can ingest events" to "any same-UID process that has read the token
|
|
47
|
+
file" - still bounded by the same trust floor as ``~/.ssh/id_rsa`` but with
|
|
48
|
+
an explicit audit boundary, an ``ingest`` ``kind`` whitelist, and rejection
|
|
49
|
+
of accidental connections from misconfigured tools.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import asyncio
|
|
55
|
+
import contextlib
|
|
56
|
+
import json
|
|
57
|
+
import logging
|
|
58
|
+
import os
|
|
59
|
+
import secrets
|
|
60
|
+
from dataclasses import dataclass, field
|
|
61
|
+
from datetime import datetime, timezone
|
|
62
|
+
from pathlib import Path
|
|
63
|
+
from typing import TYPE_CHECKING, Any
|
|
64
|
+
|
|
65
|
+
from alter_runtime.cap_cache import DaemonCapCache
|
|
66
|
+
from alter_runtime.config import DaemonConfig
|
|
67
|
+
from alter_runtime.daemon import Component
|
|
68
|
+
from alter_runtime.floor_loop import FloorState, is_safety_critical_call
|
|
69
|
+
from alter_runtime.subscribers.bus import EventBus
|
|
70
|
+
from alter_runtime.subscribers.sse import SSEFrame
|
|
71
|
+
|
|
72
|
+
if TYPE_CHECKING:
|
|
73
|
+
from alter_runtime.config import Session
|
|
74
|
+
from alter_runtime.subscribers.agent_frames import AgentFrameSubscriber
|
|
75
|
+
|
|
76
|
+
__all__ = ["UnixSocketServer"]
|
|
77
|
+
|
|
78
|
+
logger = logging.getLogger("alter_runtime.sockets.unix")
|
|
79
|
+
|
|
80
|
+
#: Maximum length of a single JSON-RPC request line. Large enough for a
|
|
81
|
+
#: typical ingest payload but small enough to prevent a runaway client from
|
|
82
|
+
#: exhausting memory.
|
|
83
|
+
MAX_LINE_BYTES: int = 256 * 1024
|
|
84
|
+
|
|
85
|
+
#: Topics to forward to subscribed clients.
|
|
86
|
+
FORWARDED_TOPICS: tuple[str, ...] = (
|
|
87
|
+
"identity.frame",
|
|
88
|
+
"identity.event",
|
|
89
|
+
"identity.connected",
|
|
90
|
+
"identity.disconnected",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
#: Topic used for egress - clients that call ``ingest`` publish here for the
|
|
94
|
+
#: eventual ``LocalSignalForwarder`` (W2.2d) to POST to the DO.
|
|
95
|
+
EGRESS_TOPIC: str = "local.signal"
|
|
96
|
+
|
|
97
|
+
#: Filename (relative to the socket's parent directory) of the daemon-minted
|
|
98
|
+
#: auth token. Written ``0o600`` at server startup; same-UID clients read it
|
|
99
|
+
#: and present the value via ``{"method":"auth","token":"<t>"}``.
|
|
100
|
+
TOKEN_FILENAME: str = "alter-daemon-token"
|
|
101
|
+
|
|
102
|
+
#: ``ingest`` kinds external clients may publish onto the bus. Internal
|
|
103
|
+
#: subscribers (``GitWatcher``, ``EbpfSubscriber``, etc.) publish directly via
|
|
104
|
+
#: the in-process bus and never traverse this socket; the whitelist here is
|
|
105
|
+
#: scoped to what plugins / sibling MCP servers are permitted to inject.
|
|
106
|
+
INGEST_KIND_WHITELIST: frozenset[str] = frozenset(
|
|
107
|
+
{
|
|
108
|
+
"vault_consent_grant",
|
|
109
|
+
"vault_consent_revoke",
|
|
110
|
+
"vault_inference_emit",
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(eq=False)
|
|
116
|
+
class _ClientConnection:
|
|
117
|
+
"""Per-client book-keeping.
|
|
118
|
+
|
|
119
|
+
``eq=False`` keeps the default ``__hash__`` (object identity) so that
|
|
120
|
+
instances can live in a ``set()``. Structural equality between client
|
|
121
|
+
sessions has no meaning here - each connection is its own identity.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
reader: asyncio.StreamReader
|
|
125
|
+
writer: asyncio.StreamWriter
|
|
126
|
+
subscribed: bool = False
|
|
127
|
+
#: ``True`` once the client has presented the daemon token via the
|
|
128
|
+
#: ``auth`` method. ``ping`` is the only method allowed before this flips;
|
|
129
|
+
#: every other method short-circuits with ``{"ok": false, "error":
|
|
130
|
+
#: "auth required"}`` and the connection is closed.
|
|
131
|
+
authenticated: bool = False
|
|
132
|
+
#: Queue used to fan out bus events to this specific client without
|
|
133
|
+
#: blocking the bus publisher.
|
|
134
|
+
queue: asyncio.Queue[dict[str, Any]] = field(default_factory=lambda: asyncio.Queue(maxsize=256))
|
|
135
|
+
|
|
136
|
+
def peer(self) -> str:
|
|
137
|
+
"""Human-readable identifier for log lines."""
|
|
138
|
+
try:
|
|
139
|
+
info = self.writer.get_extra_info("sockname")
|
|
140
|
+
return str(info) if info else "<unix>"
|
|
141
|
+
except Exception: # pragma: no cover
|
|
142
|
+
return "<unix>"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class UnixSocketServer(Component):
|
|
146
|
+
"""Local JSON-RPC server on a Unix-domain socket.
|
|
147
|
+
|
|
148
|
+
Parameters
|
|
149
|
+
----------
|
|
150
|
+
config:
|
|
151
|
+
Loaded :class:`DaemonConfig` - only ``unix_socket`` is consulted.
|
|
152
|
+
bus:
|
|
153
|
+
Shared :class:`EventBus`. The server subscribes to identity topics
|
|
154
|
+
for fan-out and publishes to ``local.signal`` on ingest.
|
|
155
|
+
session:
|
|
156
|
+
Authenticated alter-cli :class:`Session` - used for the ``whoami``
|
|
157
|
+
method. Optional so tests can start the server without a session.
|
|
158
|
+
socket_path:
|
|
159
|
+
Optional override for the socket path (tests inject a ``tmp_path``
|
|
160
|
+
location so they don't collide with a real daemon).
|
|
161
|
+
auth_token:
|
|
162
|
+
Optional explicit auth token. When ``None`` (the production default),
|
|
163
|
+
:meth:`run` mints a fresh ``secrets.token_urlsafe(32)`` value at
|
|
164
|
+
startup and writes it next to the socket as ``alter-daemon-token``
|
|
165
|
+
(mode ``0o600``). Tests may inject a deterministic value to avoid
|
|
166
|
+
the disk write - when provided, no token file is created.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
name = "unix_socket"
|
|
170
|
+
|
|
171
|
+
def __init__(
|
|
172
|
+
self,
|
|
173
|
+
config: DaemonConfig,
|
|
174
|
+
bus: EventBus,
|
|
175
|
+
session: Session | None = None,
|
|
176
|
+
*,
|
|
177
|
+
socket_path: Path | None = None,
|
|
178
|
+
auth_token: str | None = None,
|
|
179
|
+
agent_frames_subscriber: AgentFrameSubscriber | None = None,
|
|
180
|
+
floor_state: FloorState | None = None,
|
|
181
|
+
cap_cache: DaemonCapCache | None = None,
|
|
182
|
+
http_client: Any | None = None,
|
|
183
|
+
) -> None:
|
|
184
|
+
self._config = config
|
|
185
|
+
self._bus = bus
|
|
186
|
+
self._session = session
|
|
187
|
+
self._socket_path: Path = socket_path if socket_path is not None else config.unix_socket
|
|
188
|
+
# Optional reference to the AgentFrameSubscriber that maintains the
|
|
189
|
+
# in-memory instrument roster. When present, the ``agent/roster``
|
|
190
|
+
# method returns the live roster without a DO round-trip. When absent,
|
|
191
|
+
# ``agent/roster`` returns an empty list so the daemon starts cleanly
|
|
192
|
+
# even when the subscriber has not been registered yet.
|
|
193
|
+
self._agent_frames_subscriber: AgentFrameSubscriber | None = agent_frames_subscriber
|
|
194
|
+
# D-MIN-VERSION-FLOOR-1 Phase 3 — shared floor state. When the
|
|
195
|
+
# FloorLoop reports below floor, every authenticated method except
|
|
196
|
+
# the ping/auth handshake and A15.3 safety-critical carve-outs
|
|
197
|
+
# short-circuits with the canonical ``client_below_floor`` envelope.
|
|
198
|
+
# When ``None`` (tests / degraded daemon) the gate is effectively
|
|
199
|
+
# a no-op — the server-side backend gate remains load-bearing.
|
|
200
|
+
self._floor_state: FloorState | None = floor_state
|
|
201
|
+
self._auth_token: str | None = auth_token
|
|
202
|
+
self._token_path: Path | None = None
|
|
203
|
+
self._token_minted: bool = False
|
|
204
|
+
self._server: asyncio.base_events.Server | None = None
|
|
205
|
+
self._clients: set[_ClientConnection] = set()
|
|
206
|
+
self._stop_event = asyncio.Event()
|
|
207
|
+
# Callback handles registered on the bus - kept so we can unsubscribe
|
|
208
|
+
# cleanly on stop.
|
|
209
|
+
self._bus_handlers: dict[str, Any] = {}
|
|
210
|
+
# Machine-wide capability and query cache shared across all clients.
|
|
211
|
+
# When ``None`` a fresh instance is created on first use. Tests may
|
|
212
|
+
# inject a pre-configured instance.
|
|
213
|
+
self._cap_cache: DaemonCapCache | None = cap_cache
|
|
214
|
+
# Optional HTTP client for cap_cache use. When provided the
|
|
215
|
+
# cap_cache's own client is overridden by passing it explicitly on
|
|
216
|
+
# each call so tests can inject a mock transport.
|
|
217
|
+
self._http_client: Any | None = http_client
|
|
218
|
+
|
|
219
|
+
# ------------------------------------------------------------------
|
|
220
|
+
# Component lifecycle
|
|
221
|
+
# ------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
async def run(self) -> None:
|
|
224
|
+
"""Bind the socket and serve clients until stop() is called."""
|
|
225
|
+
parent = self._socket_path.parent
|
|
226
|
+
|
|
227
|
+
# Pentest 2026-04-26 (HIGH): the parent directory holds the socket
|
|
228
|
+
# AND the daemon token file. mkdir(mode=0o700) only sets the mode
|
|
229
|
+
# on creation, not when the parent already exists. We must (a)
|
|
230
|
+
# create with 0o700, AND (b) chmod-fix any pre-existing parent so
|
|
231
|
+
# an inherited 0o755 from a non-XDG fallback (like /tmp/alter-1000/)
|
|
232
|
+
# cannot leak the token to other UIDs. Refuse outright when the
|
|
233
|
+
# fallback root is /tmp and XDG_RUNTIME_DIR is unset - /tmp itself
|
|
234
|
+
# is 1777 and a per-user subdir there is the wrong trust posture
|
|
235
|
+
# for shipping signed-frame credentials.
|
|
236
|
+
if not parent.exists():
|
|
237
|
+
parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
238
|
+
else:
|
|
239
|
+
with contextlib.suppress(OSError):
|
|
240
|
+
os.chmod(parent, 0o700)
|
|
241
|
+
self._validate_parent_dir(parent)
|
|
242
|
+
|
|
243
|
+
# If a stale socket file exists (daemon crashed without cleanup),
|
|
244
|
+
# remove it. We only do this if it's an actual socket - never a
|
|
245
|
+
# regular file.
|
|
246
|
+
if self._socket_path.exists():
|
|
247
|
+
try:
|
|
248
|
+
mode = os.stat(self._socket_path).st_mode
|
|
249
|
+
except FileNotFoundError:
|
|
250
|
+
mode = 0
|
|
251
|
+
import stat as _stat
|
|
252
|
+
|
|
253
|
+
if _stat.S_ISSOCK(mode):
|
|
254
|
+
logger.info("unix_socket removing stale socket at %s", self._socket_path)
|
|
255
|
+
with contextlib.suppress(OSError):
|
|
256
|
+
self._socket_path.unlink()
|
|
257
|
+
|
|
258
|
+
logger.info("unix_socket binding path=%s", self._socket_path)
|
|
259
|
+
|
|
260
|
+
# Tighten the umask so the socket is created mode 0o600 even if the
|
|
261
|
+
# inherited shell umask is 0o022.
|
|
262
|
+
previous_umask = os.umask(0o077)
|
|
263
|
+
try:
|
|
264
|
+
self._server = await asyncio.start_unix_server(
|
|
265
|
+
self._handle_client,
|
|
266
|
+
path=str(self._socket_path),
|
|
267
|
+
)
|
|
268
|
+
finally:
|
|
269
|
+
os.umask(previous_umask)
|
|
270
|
+
|
|
271
|
+
# Post-bind chmod in case the kernel / fs didn't honour umask.
|
|
272
|
+
with contextlib.suppress(OSError):
|
|
273
|
+
os.chmod(self._socket_path, 0o600)
|
|
274
|
+
|
|
275
|
+
# Mint and persist the auth token if the caller didn't provide one.
|
|
276
|
+
# Same-UID clients read this file and present its value via the
|
|
277
|
+
# ``auth`` method before any other call is accepted.
|
|
278
|
+
if self._auth_token is None:
|
|
279
|
+
self._auth_token = secrets.token_urlsafe(32)
|
|
280
|
+
self._token_minted = True
|
|
281
|
+
self._token_path = self._socket_path.parent / TOKEN_FILENAME
|
|
282
|
+
previous_token_umask = os.umask(0o077)
|
|
283
|
+
try:
|
|
284
|
+
self._token_path.write_text(self._auth_token, encoding="utf-8")
|
|
285
|
+
finally:
|
|
286
|
+
os.umask(previous_token_umask)
|
|
287
|
+
with contextlib.suppress(OSError):
|
|
288
|
+
os.chmod(self._token_path, 0o600)
|
|
289
|
+
logger.info("unix_socket auth token written path=%s", self._token_path)
|
|
290
|
+
|
|
291
|
+
# Subscribe to the identity topics we fan out.
|
|
292
|
+
for topic in FORWARDED_TOPICS:
|
|
293
|
+
handler = self._make_forwarder(topic)
|
|
294
|
+
self._bus.subscribe(topic, handler)
|
|
295
|
+
self._bus_handlers[topic] = handler
|
|
296
|
+
|
|
297
|
+
try:
|
|
298
|
+
await self._stop_event.wait()
|
|
299
|
+
finally:
|
|
300
|
+
logger.info("unix_socket stopping path=%s", self._socket_path)
|
|
301
|
+
await self._shutdown()
|
|
302
|
+
|
|
303
|
+
async def stop(self) -> None:
|
|
304
|
+
"""Cooperative shutdown."""
|
|
305
|
+
self._stop_event.set()
|
|
306
|
+
|
|
307
|
+
@staticmethod
|
|
308
|
+
def _validate_parent_dir(parent: Path) -> None:
|
|
309
|
+
"""Refuse to bind the socket if the parent directory is unsafe.
|
|
310
|
+
|
|
311
|
+
Pentest 2026-04-26 (HIGH): the daemon token file lives next to the
|
|
312
|
+
socket - if its parent isn't user-owned and 0o700-or-tighter, any
|
|
313
|
+
same-UID-or-higher process can read the token and impersonate a
|
|
314
|
+
legitimate client. The XDG_RUNTIME_DIR location is already
|
|
315
|
+
per-user 0o700 by systemd convention; the legacy fallback
|
|
316
|
+
``/tmp/alter-<uid>.sock`` puts the token directly in /tmp (1777)
|
|
317
|
+
which is the wrong trust posture for shipping. Refuse outright in
|
|
318
|
+
that case so operators see the misconfiguration instead of
|
|
319
|
+
silently inheriting a world-readable token directory.
|
|
320
|
+
"""
|
|
321
|
+
# /tmp itself is 1777 by design - never permit it as the parent.
|
|
322
|
+
# Tests use tmp_path (/tmp/pytest-of-<user>/...) which has its
|
|
323
|
+
# own per-user owner; that case is allowed because the parent
|
|
324
|
+
# we check is the socket's *direct* parent, not /tmp.
|
|
325
|
+
if str(parent) == "/tmp":
|
|
326
|
+
raise PermissionError(
|
|
327
|
+
"unix_socket refusing to bind under /tmp directly - set "
|
|
328
|
+
"XDG_RUNTIME_DIR (e.g. /run/user/$UID) or override "
|
|
329
|
+
"ALTER_RUNTIME_SOCKET to a user-owned 0o700 directory."
|
|
330
|
+
)
|
|
331
|
+
if hasattr(os, "getuid"):
|
|
332
|
+
try:
|
|
333
|
+
stat_result = os.stat(parent)
|
|
334
|
+
except OSError as exc:
|
|
335
|
+
raise PermissionError(f"unix_socket cannot stat parent {parent}: {exc}") from exc
|
|
336
|
+
if stat_result.st_uid != os.getuid():
|
|
337
|
+
raise PermissionError(
|
|
338
|
+
f"unix_socket refusing to bind: parent {parent} is owned "
|
|
339
|
+
f"by uid={stat_result.st_uid}, expected uid={os.getuid()}."
|
|
340
|
+
)
|
|
341
|
+
mode_bits = stat_result.st_mode & 0o777
|
|
342
|
+
if mode_bits & 0o077:
|
|
343
|
+
raise PermissionError(
|
|
344
|
+
f"unix_socket refusing to bind: parent {parent} mode is "
|
|
345
|
+
f"{mode_bits:#o}, expected 0o700 (group/other bits unset)."
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
async def _shutdown(self) -> None:
|
|
349
|
+
"""Close the server, drop subscribers, kick connected clients."""
|
|
350
|
+
for topic, handler in list(self._bus_handlers.items()):
|
|
351
|
+
self._bus.unsubscribe(topic, handler)
|
|
352
|
+
self._bus_handlers.clear()
|
|
353
|
+
|
|
354
|
+
if self._server is not None:
|
|
355
|
+
self._server.close()
|
|
356
|
+
with contextlib.suppress(Exception):
|
|
357
|
+
await self._server.wait_closed()
|
|
358
|
+
self._server = None
|
|
359
|
+
|
|
360
|
+
for client in list(self._clients):
|
|
361
|
+
with contextlib.suppress(Exception):
|
|
362
|
+
client.writer.close()
|
|
363
|
+
|
|
364
|
+
# Remove the socket file so the next run() call starts clean.
|
|
365
|
+
with contextlib.suppress(FileNotFoundError, OSError):
|
|
366
|
+
self._socket_path.unlink()
|
|
367
|
+
|
|
368
|
+
# Remove the daemon-minted token file. Tokens injected via the
|
|
369
|
+
# constructor (tests, embedders) are not on disk and not our problem.
|
|
370
|
+
if self._token_minted and self._token_path is not None:
|
|
371
|
+
with contextlib.suppress(FileNotFoundError, OSError):
|
|
372
|
+
self._token_path.unlink()
|
|
373
|
+
self._token_minted = False
|
|
374
|
+
self._token_path = None
|
|
375
|
+
|
|
376
|
+
# ------------------------------------------------------------------
|
|
377
|
+
# Connection handling
|
|
378
|
+
# ------------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
async def _handle_client(
|
|
381
|
+
self,
|
|
382
|
+
reader: asyncio.StreamReader,
|
|
383
|
+
writer: asyncio.StreamWriter,
|
|
384
|
+
) -> None:
|
|
385
|
+
"""Per-client coroutine - serves one connection until it closes."""
|
|
386
|
+
client = _ClientConnection(reader=reader, writer=writer)
|
|
387
|
+
self._clients.add(client)
|
|
388
|
+
logger.debug("unix_socket client connected peer=%s", client.peer())
|
|
389
|
+
|
|
390
|
+
# Kick off the fan-out pump in parallel; it drains `client.queue`.
|
|
391
|
+
pump_task = asyncio.create_task(self._pump_queue(client))
|
|
392
|
+
|
|
393
|
+
try:
|
|
394
|
+
while not self._stop_event.is_set():
|
|
395
|
+
try:
|
|
396
|
+
line = await reader.readuntil(b"\n")
|
|
397
|
+
except asyncio.IncompleteReadError:
|
|
398
|
+
break
|
|
399
|
+
except asyncio.LimitOverrunError:
|
|
400
|
+
# Client sent > 64KB without a newline - drain the rest
|
|
401
|
+
# of the stream and hang up.
|
|
402
|
+
logger.warning("unix_socket client line too long peer=%s", client.peer())
|
|
403
|
+
break
|
|
404
|
+
if not line or len(line) > MAX_LINE_BYTES:
|
|
405
|
+
break
|
|
406
|
+
await self._dispatch_line(client, line)
|
|
407
|
+
except ConnectionError:
|
|
408
|
+
pass
|
|
409
|
+
finally:
|
|
410
|
+
pump_task.cancel()
|
|
411
|
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
412
|
+
await pump_task
|
|
413
|
+
with contextlib.suppress(Exception):
|
|
414
|
+
writer.close()
|
|
415
|
+
await writer.wait_closed()
|
|
416
|
+
self._clients.discard(client)
|
|
417
|
+
logger.debug("unix_socket client disconnected peer=%s", client.peer())
|
|
418
|
+
|
|
419
|
+
async def _dispatch_line(self, client: _ClientConnection, line: bytes) -> None:
|
|
420
|
+
"""Parse one JSON-RPC request line and dispatch it."""
|
|
421
|
+
try:
|
|
422
|
+
request = json.loads(line.decode("utf-8", errors="replace"))
|
|
423
|
+
except json.JSONDecodeError as exc:
|
|
424
|
+
await self._write(client, {"ok": False, "error": f"invalid json: {exc}"})
|
|
425
|
+
return
|
|
426
|
+
|
|
427
|
+
if not isinstance(request, dict):
|
|
428
|
+
await self._write(client, {"ok": False, "error": "request must be a JSON object"})
|
|
429
|
+
return
|
|
430
|
+
|
|
431
|
+
method = request.get("method")
|
|
432
|
+
|
|
433
|
+
# ``ping`` is the only method allowed pre-auth; it serves as a
|
|
434
|
+
# liveness probe for clients that haven't read the token yet.
|
|
435
|
+
if method == "ping":
|
|
436
|
+
await self._write(client, {"ok": True, "pong": True})
|
|
437
|
+
return
|
|
438
|
+
|
|
439
|
+
# ``auth`` performs the token handshake. Constant-time compare so a
|
|
440
|
+
# malicious same-UID process can't time-side-channel the token.
|
|
441
|
+
if method == "auth":
|
|
442
|
+
token = request.get("token")
|
|
443
|
+
expected = self._auth_token
|
|
444
|
+
if (
|
|
445
|
+
not isinstance(token, str)
|
|
446
|
+
or expected is None
|
|
447
|
+
or not secrets.compare_digest(token, expected)
|
|
448
|
+
):
|
|
449
|
+
logger.warning(
|
|
450
|
+
"unix_socket auth failed peer=%s - closing connection",
|
|
451
|
+
client.peer(),
|
|
452
|
+
)
|
|
453
|
+
await self._write(client, {"ok": False, "error": "auth: invalid token"})
|
|
454
|
+
with contextlib.suppress(Exception):
|
|
455
|
+
client.writer.close()
|
|
456
|
+
return
|
|
457
|
+
client.authenticated = True
|
|
458
|
+
await self._write(client, {"ok": True, "authenticated": True})
|
|
459
|
+
return
|
|
460
|
+
|
|
461
|
+
# Every other method requires prior auth. Refuse and close on first
|
|
462
|
+
# offence - a same-UID process that doesn't speak the protocol is
|
|
463
|
+
# almost certainly misconfigured rather than malicious, but either
|
|
464
|
+
# way it has no business holding the connection open.
|
|
465
|
+
if not client.authenticated:
|
|
466
|
+
await self._write(client, {"ok": False, "error": "auth required"})
|
|
467
|
+
with contextlib.suppress(Exception):
|
|
468
|
+
client.writer.close()
|
|
469
|
+
return
|
|
470
|
+
|
|
471
|
+
# D-MIN-VERSION-FLOOR-1 Phase 3 — floor gate.
|
|
472
|
+
#
|
|
473
|
+
# Locked → emit the canonical ``client_below_floor`` envelope on
|
|
474
|
+
# every authenticated method, except the A15.3 safety-critical
|
|
475
|
+
# carve-out (``ingest`` with ``urgency: critical|emergency``). The
|
|
476
|
+
# response body is byte-shape-equal to the Phase 1 server middleware
|
|
477
|
+
# reject (``backend/app/middleware/min_version_floor.py:_envelope``):
|
|
478
|
+
# ``{"error": {"code": "client_below_floor", "message": "...",
|
|
479
|
+
# "client_version": "...", "min_version": "...", "upgrade_cmd":
|
|
480
|
+
# "...", "channel": "..."}}``.
|
|
481
|
+
#
|
|
482
|
+
# The handshake (``ping``, ``auth``) is permitted regardless of
|
|
483
|
+
# floor state — analogous to the MCP ``initialize`` permitted in
|
|
484
|
+
# DR §6 so callers see a clean error envelope rather than a TCP
|
|
485
|
+
# reset. Both methods exited above this point.
|
|
486
|
+
if self._floor_state is not None and self._floor_state.is_locked():
|
|
487
|
+
if not is_safety_critical_call(method, request):
|
|
488
|
+
envelope = self._floor_state.envelope_payload()
|
|
489
|
+
if envelope is not None:
|
|
490
|
+
floor_response: dict[str, Any] = {"ok": False, **envelope}
|
|
491
|
+
await self._write(client, floor_response)
|
|
492
|
+
return
|
|
493
|
+
|
|
494
|
+
if method == "whoami":
|
|
495
|
+
await self._write(
|
|
496
|
+
client,
|
|
497
|
+
{
|
|
498
|
+
"ok": True,
|
|
499
|
+
"handle": self._session.handle if self._session else None,
|
|
500
|
+
"consent_tier": self._session.consent_tier if self._session else None,
|
|
501
|
+
},
|
|
502
|
+
)
|
|
503
|
+
elif method == "agent/roster":
|
|
504
|
+
# D-AGENT-CHANNEL-1 §8 — local read of current online instruments
|
|
505
|
+
# without a DO round-trip. Delegates to the AgentFrameSubscriber's
|
|
506
|
+
# in-memory roster which is populated from observed agent_frame
|
|
507
|
+
# deliveries. Returns an empty list when the subscriber has not
|
|
508
|
+
# been wired (daemon degraded mode or subscriber not yet registered).
|
|
509
|
+
roster: list[Any] = []
|
|
510
|
+
if self._agent_frames_subscriber is not None:
|
|
511
|
+
try:
|
|
512
|
+
roster = self._agent_frames_subscriber.get_roster()
|
|
513
|
+
except Exception as exc: # noqa: BLE001 — defensive
|
|
514
|
+
logger.warning("unix_socket agent/roster: roster read failed: %s", exc)
|
|
515
|
+
await self._write(client, {"ok": True, "roster": roster})
|
|
516
|
+
elif method == "subscribe":
|
|
517
|
+
client.subscribed = True
|
|
518
|
+
await self._write(client, {"ok": True, "subscribed": True})
|
|
519
|
+
elif method == "unsubscribe":
|
|
520
|
+
client.subscribed = False
|
|
521
|
+
await self._write(client, {"ok": True, "subscribed": False})
|
|
522
|
+
elif method == "ingest":
|
|
523
|
+
kind = request.get("kind")
|
|
524
|
+
payload = request.get("payload", {})
|
|
525
|
+
if not isinstance(kind, str) or not isinstance(payload, dict):
|
|
526
|
+
await self._write(
|
|
527
|
+
client,
|
|
528
|
+
{"ok": False, "error": "ingest requires kind:str and payload:dict"},
|
|
529
|
+
)
|
|
530
|
+
return
|
|
531
|
+
if kind not in INGEST_KIND_WHITELIST:
|
|
532
|
+
await self._write(
|
|
533
|
+
client,
|
|
534
|
+
{"ok": False, "error": f"ingest kind not permitted: {kind!r}"},
|
|
535
|
+
)
|
|
536
|
+
return
|
|
537
|
+
# Mint per-kind side-effects (event_id / revocation_token /
|
|
538
|
+
# timestamps) before publishing so the bus payload carries the
|
|
539
|
+
# same identifiers the client receives in the response. The
|
|
540
|
+
# plaintext revocation_token is rendered ONCE in the plugin's
|
|
541
|
+
# Notice modal - only its SHA-256 hash is persisted client-side
|
|
542
|
+
# and in the backend ledger. The daemon forwards the plaintext
|
|
543
|
+
# to the backend so the ledger can compute the hash on receipt.
|
|
544
|
+
response: dict[str, Any] = {"ok": True, "ingested": True}
|
|
545
|
+
enriched_payload: dict[str, Any] = dict(payload)
|
|
546
|
+
if kind == "vault_consent_grant":
|
|
547
|
+
event_id = "evt-" + secrets.token_hex(8)
|
|
548
|
+
revocation_token = secrets.token_urlsafe(32)
|
|
549
|
+
granted_at = datetime.now(tz=timezone.utc).isoformat()
|
|
550
|
+
response["event_id"] = event_id
|
|
551
|
+
response["revocation_token"] = revocation_token
|
|
552
|
+
response["granted_at"] = granted_at
|
|
553
|
+
enriched_payload["event_id"] = event_id
|
|
554
|
+
enriched_payload["revocation_token"] = revocation_token
|
|
555
|
+
enriched_payload["granted_at"] = granted_at
|
|
556
|
+
elif kind == "vault_consent_revoke":
|
|
557
|
+
revoked_at = datetime.now(tz=timezone.utc).isoformat()
|
|
558
|
+
response["revoked"] = True
|
|
559
|
+
response["revoked_at"] = revoked_at
|
|
560
|
+
enriched_payload["revoked_at"] = revoked_at
|
|
561
|
+
await self._bus.publish(
|
|
562
|
+
EGRESS_TOPIC,
|
|
563
|
+
{
|
|
564
|
+
"kind": kind,
|
|
565
|
+
"payload": enriched_payload,
|
|
566
|
+
"source": "unix_socket",
|
|
567
|
+
},
|
|
568
|
+
)
|
|
569
|
+
await self._write(client, response)
|
|
570
|
+
elif method == "cap.get":
|
|
571
|
+
# Machine-wide cap-JWT cache. Collapses N CC-bridge cap-mint
|
|
572
|
+
# calls into one minting identity per handle-scope set.
|
|
573
|
+
#
|
|
574
|
+
# Request: {"method": "cap.get", "scopes": ["scope1", ...]}
|
|
575
|
+
# Response: {"ok": true, "capability": "<jwt>", "expires_at": "<iso>"}
|
|
576
|
+
# or {"ok": false, "error": "<reason>"}
|
|
577
|
+
scopes = request.get("scopes")
|
|
578
|
+
if not isinstance(scopes, list) or not all(isinstance(s, str) for s in scopes):
|
|
579
|
+
await self._write(
|
|
580
|
+
client,
|
|
581
|
+
{"ok": False, "error": "cap.get requires scopes: string[]"},
|
|
582
|
+
)
|
|
583
|
+
return
|
|
584
|
+
cache = self._ensure_cap_cache()
|
|
585
|
+
http = self._http_client
|
|
586
|
+
result = await cache.get_cap(scopes, client=http)
|
|
587
|
+
await self._write(client, result)
|
|
588
|
+
elif method == "query.get":
|
|
589
|
+
# Cap-gated query cache. Single GET per (path, params) every 15 s.
|
|
590
|
+
#
|
|
591
|
+
# Request: {"method": "query.get", "path": "<str>", "params": {...}}
|
|
592
|
+
# Response: {"ok": true, "body": <any>, "cached_at": <float>}
|
|
593
|
+
# or {"ok": false, "error": "<reason>"}
|
|
594
|
+
path = request.get("path")
|
|
595
|
+
params = request.get("params")
|
|
596
|
+
if not isinstance(path, str) or not path:
|
|
597
|
+
await self._write(
|
|
598
|
+
client,
|
|
599
|
+
{"ok": False, "error": "query.get requires path: str"},
|
|
600
|
+
)
|
|
601
|
+
return
|
|
602
|
+
if params is not None and not isinstance(params, dict):
|
|
603
|
+
await self._write(
|
|
604
|
+
client,
|
|
605
|
+
{"ok": False, "error": "query.get params must be an object or absent"},
|
|
606
|
+
)
|
|
607
|
+
return
|
|
608
|
+
cache = self._ensure_cap_cache()
|
|
609
|
+
http = self._http_client
|
|
610
|
+
result = await cache.get_query(path, params, client=http)
|
|
611
|
+
await self._write(client, result)
|
|
612
|
+
else:
|
|
613
|
+
await self._write(
|
|
614
|
+
client,
|
|
615
|
+
{"ok": False, "error": f"unknown method: {method!r}"},
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
async def _write(self, client: _ClientConnection, obj: dict[str, Any]) -> None:
|
|
619
|
+
"""Serialise ``obj`` as a single JSON line and send it."""
|
|
620
|
+
try:
|
|
621
|
+
body = (json.dumps(obj, separators=(",", ":")) + "\n").encode("utf-8")
|
|
622
|
+
client.writer.write(body)
|
|
623
|
+
await client.writer.drain()
|
|
624
|
+
except (ConnectionError, OSError, RuntimeError):
|
|
625
|
+
# Client went away - swallow, the reader loop will clean up.
|
|
626
|
+
pass
|
|
627
|
+
|
|
628
|
+
async def _pump_queue(self, client: _ClientConnection) -> None:
|
|
629
|
+
"""Drain the client's fan-out queue onto the wire."""
|
|
630
|
+
while True:
|
|
631
|
+
frame = await client.queue.get()
|
|
632
|
+
if not client.subscribed:
|
|
633
|
+
continue
|
|
634
|
+
await self._write(client, frame)
|
|
635
|
+
|
|
636
|
+
# ------------------------------------------------------------------
|
|
637
|
+
# Bus fan-out
|
|
638
|
+
# ------------------------------------------------------------------
|
|
639
|
+
|
|
640
|
+
def _make_forwarder(self, topic: str):
|
|
641
|
+
"""Return a bus subscriber that enqueues events for every client."""
|
|
642
|
+
|
|
643
|
+
async def _forwarder(payload: Any) -> None:
|
|
644
|
+
frame: dict[str, Any]
|
|
645
|
+
if isinstance(payload, SSEFrame):
|
|
646
|
+
frame = {
|
|
647
|
+
"event": topic,
|
|
648
|
+
"data": {
|
|
649
|
+
"event": payload.event,
|
|
650
|
+
"data": payload.data,
|
|
651
|
+
"id": payload.id,
|
|
652
|
+
},
|
|
653
|
+
}
|
|
654
|
+
else:
|
|
655
|
+
frame = {"event": topic, "data": payload}
|
|
656
|
+
for client in list(self._clients):
|
|
657
|
+
if not client.subscribed:
|
|
658
|
+
continue
|
|
659
|
+
try:
|
|
660
|
+
client.queue.put_nowait(frame)
|
|
661
|
+
except asyncio.QueueFull:
|
|
662
|
+
logger.warning(
|
|
663
|
+
"unix_socket client queue full - dropping frame peer=%s",
|
|
664
|
+
client.peer(),
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
return _forwarder
|
|
668
|
+
|
|
669
|
+
# ------------------------------------------------------------------
|
|
670
|
+
# Cap/query cache helpers
|
|
671
|
+
# ------------------------------------------------------------------
|
|
672
|
+
|
|
673
|
+
def _ensure_cap_cache(self) -> DaemonCapCache:
|
|
674
|
+
"""Return the shared :class:`DaemonCapCache`, constructing it on first call."""
|
|
675
|
+
if self._cap_cache is None:
|
|
676
|
+
self._cap_cache = DaemonCapCache(self._session)
|
|
677
|
+
return self._cap_cache
|
|
678
|
+
|
|
679
|
+
# ------------------------------------------------------------------
|
|
680
|
+
# Test introspection
|
|
681
|
+
# ------------------------------------------------------------------
|
|
682
|
+
|
|
683
|
+
@property
|
|
684
|
+
def socket_path(self) -> Path:
|
|
685
|
+
return self._socket_path
|
|
686
|
+
|
|
687
|
+
@property
|
|
688
|
+
def token_path(self) -> Path | None:
|
|
689
|
+
"""Filesystem path of the daemon-minted token, or ``None`` if the
|
|
690
|
+
token was injected via the constructor (tests / embedders) and never
|
|
691
|
+
written to disk."""
|
|
692
|
+
return self._token_path
|
|
693
|
+
|
|
694
|
+
@property
|
|
695
|
+
def auth_token(self) -> str | None:
|
|
696
|
+
"""The current auth token. Exposed for tests and same-process
|
|
697
|
+
embedders; production clients read it from :attr:`token_path`."""
|
|
698
|
+
return self._auth_token
|
|
699
|
+
|
|
700
|
+
@property
|
|
701
|
+
def client_count(self) -> int:
|
|
702
|
+
return len(self._clients)
|