agent-brain-uds 10.1.2__tar.gz
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.
- agent_brain_uds-10.1.2/PKG-INFO +44 -0
- agent_brain_uds-10.1.2/README.md +20 -0
- agent_brain_uds-10.1.2/agent_brain_uds/__init__.py +62 -0
- agent_brain_uds-10.1.2/agent_brain_uds/client.py +84 -0
- agent_brain_uds-10.1.2/agent_brain_uds/errors.py +74 -0
- agent_brain_uds-10.1.2/agent_brain_uds/paths.py +200 -0
- agent_brain_uds-10.1.2/agent_brain_uds/permissions.py +110 -0
- agent_brain_uds-10.1.2/pyproject.toml +61 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: agent-brain-uds
|
|
3
|
+
Version: 10.1.2
|
|
4
|
+
Summary: Agent Brain UDS - Unix-domain-socket transport for Agent Brain (socket path resolution, permission validation, httpx UDS client factory)
|
|
5
|
+
Home-page: https://github.com/SpillwaveSolutions/agent-brain
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: agent-brain,uds,unix-domain-socket,transport,httpx,ipc
|
|
8
|
+
Author: Spillwave Solutions
|
|
9
|
+
Requires-Python: >=3.10,<4.0
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: POSIX
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Requires-Dist: httpx (>=0.28.0,<0.29.0)
|
|
20
|
+
Project-URL: Documentation, https://github.com/SpillwaveSolutions/agent-brain/wiki
|
|
21
|
+
Project-URL: Repository, https://github.com/SpillwaveSolutions/agent-brain
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# agent-brain-uds
|
|
25
|
+
|
|
26
|
+
Unix-domain-socket transport for [Agent Brain](https://github.com/SpillwaveSolutions/agent-brain).
|
|
27
|
+
|
|
28
|
+
**Client-side only.** This package provides:
|
|
29
|
+
|
|
30
|
+
- Socket path resolution (`resolve_socket_path`) consistent with `agent-brain-server`'s state-dir layout.
|
|
31
|
+
- Permission validation (`validate_socket`) — owner-UID match, no group/world bits, no-symlink check.
|
|
32
|
+
- `httpx`-compatible client factory (`make_client` / `make_async_client`) speaking HTTP/1.1 over UDS.
|
|
33
|
+
|
|
34
|
+
The corresponding server-side bind (`agent_brain_server.api.uds_bind`) lives in `agent-brain-server` to keep the dep direction acyclic.
|
|
35
|
+
|
|
36
|
+
## Status
|
|
37
|
+
|
|
38
|
+
Phase 0 scaffold (10.0.7) — public surface lands in Phase 1.
|
|
39
|
+
See [`docs/plans/2026-05-28-mcp-uds-transport-design.md`](../docs/plans/2026-05-28-mcp-uds-transport-design.md).
|
|
40
|
+
|
|
41
|
+
## License
|
|
42
|
+
|
|
43
|
+
MIT
|
|
44
|
+
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# agent-brain-uds
|
|
2
|
+
|
|
3
|
+
Unix-domain-socket transport for [Agent Brain](https://github.com/SpillwaveSolutions/agent-brain).
|
|
4
|
+
|
|
5
|
+
**Client-side only.** This package provides:
|
|
6
|
+
|
|
7
|
+
- Socket path resolution (`resolve_socket_path`) consistent with `agent-brain-server`'s state-dir layout.
|
|
8
|
+
- Permission validation (`validate_socket`) — owner-UID match, no group/world bits, no-symlink check.
|
|
9
|
+
- `httpx`-compatible client factory (`make_client` / `make_async_client`) speaking HTTP/1.1 over UDS.
|
|
10
|
+
|
|
11
|
+
The corresponding server-side bind (`agent_brain_server.api.uds_bind`) lives in `agent-brain-server` to keep the dep direction acyclic.
|
|
12
|
+
|
|
13
|
+
## Status
|
|
14
|
+
|
|
15
|
+
Phase 0 scaffold (10.0.7) — public surface lands in Phase 1.
|
|
16
|
+
See [`docs/plans/2026-05-28-mcp-uds-transport-design.md`](../docs/plans/2026-05-28-mcp-uds-transport-design.md).
|
|
17
|
+
|
|
18
|
+
## License
|
|
19
|
+
|
|
20
|
+
MIT
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Agent Brain UDS — Unix-domain-socket transport for Agent Brain.
|
|
2
|
+
|
|
3
|
+
Client-side only. See docs/plans/2026-05-28-mcp-uds-transport-design.md §4.1.
|
|
4
|
+
|
|
5
|
+
Public surface:
|
|
6
|
+
|
|
7
|
+
- :func:`resolve_socket_path` — Resolve the canonical UDS socket path.
|
|
8
|
+
- :func:`validate_socket` — Validate socket file ownership and permissions.
|
|
9
|
+
- :func:`make_client` / :func:`make_async_client` — ``httpx`` clients
|
|
10
|
+
pre-configured for UDS transport.
|
|
11
|
+
- :class:`AgentBrainUdsError` and subclasses — Exception hierarchy.
|
|
12
|
+
|
|
13
|
+
The server-side bind helper lives inside ``agent_brain_server`` to keep
|
|
14
|
+
the dependency direction acyclic (server has no upward deps).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from .client import (
|
|
18
|
+
BASE_URL,
|
|
19
|
+
DEFAULT_TIMEOUT,
|
|
20
|
+
make_async_client,
|
|
21
|
+
make_client,
|
|
22
|
+
)
|
|
23
|
+
from .errors import (
|
|
24
|
+
AgentBrainUdsError,
|
|
25
|
+
SocketNotFoundError,
|
|
26
|
+
SocketPathTooLongError,
|
|
27
|
+
SocketPermissionError,
|
|
28
|
+
SocketStaleError,
|
|
29
|
+
)
|
|
30
|
+
from .paths import (
|
|
31
|
+
MAX_SOCKET_PATH_BYTES,
|
|
32
|
+
POINTER_FILE_NAME,
|
|
33
|
+
SOCKET_FILE_NAME,
|
|
34
|
+
STATE_DIR_NAME,
|
|
35
|
+
resolve_socket_path,
|
|
36
|
+
resolve_state_dir,
|
|
37
|
+
write_pointer_file,
|
|
38
|
+
)
|
|
39
|
+
from .permissions import validate_socket
|
|
40
|
+
|
|
41
|
+
__version__ = "10.1.2"
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"BASE_URL",
|
|
45
|
+
"DEFAULT_TIMEOUT",
|
|
46
|
+
"MAX_SOCKET_PATH_BYTES",
|
|
47
|
+
"POINTER_FILE_NAME",
|
|
48
|
+
"SOCKET_FILE_NAME",
|
|
49
|
+
"STATE_DIR_NAME",
|
|
50
|
+
"AgentBrainUdsError",
|
|
51
|
+
"SocketNotFoundError",
|
|
52
|
+
"SocketPathTooLongError",
|
|
53
|
+
"SocketPermissionError",
|
|
54
|
+
"SocketStaleError",
|
|
55
|
+
"__version__",
|
|
56
|
+
"make_async_client",
|
|
57
|
+
"make_client",
|
|
58
|
+
"resolve_socket_path",
|
|
59
|
+
"resolve_state_dir",
|
|
60
|
+
"validate_socket",
|
|
61
|
+
"write_pointer_file",
|
|
62
|
+
]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""``httpx`` client factory for UDS transport.
|
|
2
|
+
|
|
3
|
+
Returns a configured ``httpx.Client`` / ``httpx.AsyncClient`` that speaks
|
|
4
|
+
HTTP/1.1 over the project's UDS socket. The ``base_url`` is a placeholder
|
|
5
|
+
(``http://agent-brain``) because ``httpx`` requires a URL even though UDS
|
|
6
|
+
ignores the host component — using a fixed sentinel makes log messages
|
|
7
|
+
self-documenting.
|
|
8
|
+
|
|
9
|
+
The client validates socket permissions on construction so a stale or
|
|
10
|
+
hijacked socket fails loud immediately, not on the first request.
|
|
11
|
+
|
|
12
|
+
See docs/plans/2026-05-28-mcp-uds-transport-design.md §6.3.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from .paths import resolve_socket_path
|
|
23
|
+
from .permissions import validate_socket
|
|
24
|
+
|
|
25
|
+
#: Sentinel base URL that ends up in logs / error messages.
|
|
26
|
+
BASE_URL = "http://agent-brain"
|
|
27
|
+
|
|
28
|
+
#: Default request timeout (seconds). Overridable per call site.
|
|
29
|
+
DEFAULT_TIMEOUT = 30.0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _resolve_path(
|
|
33
|
+
state_dir: Path | None,
|
|
34
|
+
socket_path: Path | None,
|
|
35
|
+
) -> Path:
|
|
36
|
+
"""Decide which socket path to use.
|
|
37
|
+
|
|
38
|
+
Precedence: explicit ``socket_path`` argument → ``AGENT_BRAIN_UDS_PATH``
|
|
39
|
+
env var → ``resolve_socket_path(state_dir)``.
|
|
40
|
+
"""
|
|
41
|
+
if socket_path is not None:
|
|
42
|
+
return socket_path
|
|
43
|
+
env_path = os.environ.get("AGENT_BRAIN_UDS_PATH")
|
|
44
|
+
if env_path:
|
|
45
|
+
return Path(env_path).expanduser()
|
|
46
|
+
return resolve_socket_path(state_dir)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def make_client(
|
|
50
|
+
*,
|
|
51
|
+
state_dir: Path | None = None,
|
|
52
|
+
socket_path: Path | None = None,
|
|
53
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
54
|
+
) -> httpx.Client:
|
|
55
|
+
"""Return a synchronous ``httpx.Client`` configured for UDS.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
state_dir: Override the state-directory resolver. Defaults to
|
|
59
|
+
``AGENT_BRAIN_STATE_DIR`` env var or CWD lookup.
|
|
60
|
+
socket_path: Override the entire path-resolution chain.
|
|
61
|
+
timeout: HTTP request timeout in seconds.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
SocketNotFoundError: when the resolved socket does not exist.
|
|
65
|
+
SocketPermissionError: when the socket fails the permission checks
|
|
66
|
+
in :mod:`agent_brain_uds.permissions`.
|
|
67
|
+
"""
|
|
68
|
+
path = _resolve_path(state_dir, socket_path)
|
|
69
|
+
validate_socket(path)
|
|
70
|
+
transport = httpx.HTTPTransport(uds=str(path))
|
|
71
|
+
return httpx.Client(transport=transport, base_url=BASE_URL, timeout=timeout)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def make_async_client(
|
|
75
|
+
*,
|
|
76
|
+
state_dir: Path | None = None,
|
|
77
|
+
socket_path: Path | None = None,
|
|
78
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
79
|
+
) -> httpx.AsyncClient:
|
|
80
|
+
"""Async counterpart of :func:`make_client`."""
|
|
81
|
+
path = _resolve_path(state_dir, socket_path)
|
|
82
|
+
validate_socket(path)
|
|
83
|
+
transport = httpx.AsyncHTTPTransport(uds=str(path))
|
|
84
|
+
return httpx.AsyncClient(transport=transport, base_url=BASE_URL, timeout=timeout)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Exception hierarchy for agent-brain-uds.
|
|
2
|
+
|
|
3
|
+
Each error carries the resolved socket path and a remediation hint so the
|
|
4
|
+
caller (CLI, MCP server, or third-party) can surface an actionable message
|
|
5
|
+
to the user without re-implementing the diagnostics.
|
|
6
|
+
|
|
7
|
+
See docs/plans/2026-05-28-mcp-uds-transport-design.md §6.7.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AgentBrainUdsError(Exception):
|
|
16
|
+
"""Base class for all agent-brain-uds errors.
|
|
17
|
+
|
|
18
|
+
Subclasses carry a ``socket_path`` attribute (when known) and a
|
|
19
|
+
``remediation`` hint that callers can show to the user.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
message: str,
|
|
25
|
+
*,
|
|
26
|
+
socket_path: Path | None = None,
|
|
27
|
+
remediation: str | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.socket_path = socket_path
|
|
31
|
+
self.remediation = remediation
|
|
32
|
+
|
|
33
|
+
def __str__(self) -> str:
|
|
34
|
+
parts = [super().__str__()]
|
|
35
|
+
if self.socket_path is not None:
|
|
36
|
+
parts.append(f"(socket: {self.socket_path})")
|
|
37
|
+
if self.remediation:
|
|
38
|
+
parts.append(f"\n → {self.remediation}")
|
|
39
|
+
return " ".join(parts)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SocketNotFoundError(AgentBrainUdsError):
|
|
43
|
+
"""No socket file at the resolved path.
|
|
44
|
+
|
|
45
|
+
Most common cause: the server has not been started with ``--uds``.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class SocketStaleError(AgentBrainUdsError):
|
|
50
|
+
"""Socket file exists but no listener is bound.
|
|
51
|
+
|
|
52
|
+
Typically left over from a crashed server process. The server's
|
|
53
|
+
own bind helper unlinks stale sockets on startup, so this should
|
|
54
|
+
be rare in practice.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SocketPermissionError(AgentBrainUdsError):
|
|
59
|
+
"""Socket file or its parent directory has unsafe permissions.
|
|
60
|
+
|
|
61
|
+
Raised when the socket file is owned by a different UID, has
|
|
62
|
+
group/world readable bits set, is a symlink, or its parent
|
|
63
|
+
directory is not mode 0700.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SocketPathTooLongError(AgentBrainUdsError):
|
|
68
|
+
"""Resolved socket path exceeds the OS sockaddr_un limit.
|
|
69
|
+
|
|
70
|
+
UDS paths are limited to ~104 bytes on macOS/BSD and ~108 on Linux.
|
|
71
|
+
The resolver falls back to ``/tmp/agent-brain-<sha8>.sock`` and writes
|
|
72
|
+
a pointer file; this error is raised only if even the fallback
|
|
73
|
+
cannot be used.
|
|
74
|
+
"""
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Socket path resolution for agent-brain-uds.
|
|
2
|
+
|
|
3
|
+
Mirrors the state-directory lookup in ``agent_brain_server.runtime`` /
|
|
4
|
+
``agent_brain_server.storage_paths`` so client and server agree on where
|
|
5
|
+
the socket lives. Handles the platform sockaddr_un length limit by
|
|
6
|
+
falling back to a short ``/tmp`` path and writing a pointer file inside
|
|
7
|
+
the state directory.
|
|
8
|
+
|
|
9
|
+
See docs/plans/2026-05-28-mcp-uds-transport-design.md §6.1.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import os
|
|
16
|
+
import stat
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from .errors import SocketPathTooLongError, SocketPermissionError
|
|
20
|
+
|
|
21
|
+
#: Default state-directory name. Mirrors ``STATE_DIR_NAME`` in the server.
|
|
22
|
+
STATE_DIR_NAME = ".agent-brain"
|
|
23
|
+
|
|
24
|
+
#: Default socket file name inside the state directory.
|
|
25
|
+
SOCKET_FILE_NAME = "agent-brain.sock"
|
|
26
|
+
|
|
27
|
+
#: Pointer-file name written alongside the canonical socket location when
|
|
28
|
+
#: the canonical path exceeds the platform limit. Contains the real
|
|
29
|
+
#: (short) socket path, one line, UTF-8.
|
|
30
|
+
POINTER_FILE_NAME = "agent-brain.sock.path"
|
|
31
|
+
|
|
32
|
+
#: Conservative sockaddr_un limit. macOS/BSD cap is 104 bytes; Linux is 108.
|
|
33
|
+
#: We use the smaller value so paths work on every supported platform.
|
|
34
|
+
MAX_SOCKET_PATH_BYTES = 104
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _short_fallback_path(state_dir: Path) -> Path:
|
|
38
|
+
"""Return a short ``/tmp`` socket path derived from the state-dir hash.
|
|
39
|
+
|
|
40
|
+
The hash makes the fallback deterministic per project, so concurrent
|
|
41
|
+
instances in different projects do not collide.
|
|
42
|
+
"""
|
|
43
|
+
digest = hashlib.sha256(str(state_dir.resolve()).encode("utf-8")).hexdigest()[:8]
|
|
44
|
+
return Path("/tmp") / f"agent-brain-{digest}.sock"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def resolve_state_dir(state_dir: Path | None = None) -> Path:
|
|
48
|
+
"""Resolve the state directory using the same precedence as the server.
|
|
49
|
+
|
|
50
|
+
Order:
|
|
51
|
+
|
|
52
|
+
1. Explicit ``state_dir`` argument.
|
|
53
|
+
2. ``AGENT_BRAIN_STATE_DIR`` environment variable.
|
|
54
|
+
3. ``<cwd>/.agent-brain/`` if it exists.
|
|
55
|
+
4. Walk up from ``cwd`` looking for ``.agent-brain/``.
|
|
56
|
+
5. Fall back to ``<cwd>/.agent-brain/`` (does not need to exist —
|
|
57
|
+
the server creates it).
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
The resolved state-directory path (not guaranteed to exist).
|
|
61
|
+
"""
|
|
62
|
+
if state_dir is not None:
|
|
63
|
+
return state_dir.resolve()
|
|
64
|
+
|
|
65
|
+
env_dir = os.environ.get("AGENT_BRAIN_STATE_DIR")
|
|
66
|
+
if env_dir:
|
|
67
|
+
return Path(env_dir).resolve()
|
|
68
|
+
|
|
69
|
+
cwd = Path.cwd().resolve()
|
|
70
|
+
candidate = cwd / STATE_DIR_NAME
|
|
71
|
+
if candidate.is_dir():
|
|
72
|
+
return candidate
|
|
73
|
+
|
|
74
|
+
# Walk upward looking for an existing .agent-brain/ directory.
|
|
75
|
+
for parent in cwd.parents:
|
|
76
|
+
candidate = parent / STATE_DIR_NAME
|
|
77
|
+
if candidate.is_dir():
|
|
78
|
+
return candidate
|
|
79
|
+
|
|
80
|
+
# Default: a (possibly non-existent) .agent-brain/ in the current dir.
|
|
81
|
+
return cwd / STATE_DIR_NAME
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def resolve_socket_path(state_dir: Path | None = None) -> Path:
|
|
85
|
+
"""Resolve the UDS socket path for the given state directory.
|
|
86
|
+
|
|
87
|
+
Reads a pointer file first if present (long-path fallback), then
|
|
88
|
+
falls back to ``<state_dir>/agent-brain.sock``. If even the canonical
|
|
89
|
+
path is too long for the platform, returns the short ``/tmp`` fallback
|
|
90
|
+
(the server is responsible for writing the pointer file when it binds).
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
SocketPathTooLongError: when even the ``/tmp`` fallback exceeds
|
|
94
|
+
the platform limit (essentially impossible, but guarded for
|
|
95
|
+
completeness).
|
|
96
|
+
"""
|
|
97
|
+
resolved_state_dir = resolve_state_dir(state_dir)
|
|
98
|
+
|
|
99
|
+
# If a previous server run dropped a pointer file, honor it — but
|
|
100
|
+
# validate it first (plan §8). The pointer file is a privilege
|
|
101
|
+
# boundary: whoever writes it controls every client's destination.
|
|
102
|
+
pointer = resolved_state_dir / POINTER_FILE_NAME
|
|
103
|
+
target = _read_pointer_file(pointer)
|
|
104
|
+
if target is not None:
|
|
105
|
+
if len(str(target).encode("utf-8")) >= MAX_SOCKET_PATH_BYTES:
|
|
106
|
+
raise SocketPathTooLongError(
|
|
107
|
+
"Pointer-file target exceeds platform socket-path limit.",
|
|
108
|
+
socket_path=target,
|
|
109
|
+
remediation=(
|
|
110
|
+
"Delete the pointer file and re-bind the server with "
|
|
111
|
+
"AGENT_BRAIN_UDS_PATH set to a path under 104 bytes."
|
|
112
|
+
),
|
|
113
|
+
)
|
|
114
|
+
return target
|
|
115
|
+
|
|
116
|
+
canonical = resolved_state_dir / SOCKET_FILE_NAME
|
|
117
|
+
if len(str(canonical).encode("utf-8")) < MAX_SOCKET_PATH_BYTES:
|
|
118
|
+
return canonical
|
|
119
|
+
|
|
120
|
+
# Canonical path is too long; use the short fallback.
|
|
121
|
+
fallback = _short_fallback_path(resolved_state_dir)
|
|
122
|
+
if len(str(fallback).encode("utf-8")) >= MAX_SOCKET_PATH_BYTES:
|
|
123
|
+
raise SocketPathTooLongError(
|
|
124
|
+
"Both canonical and /tmp fallback paths exceed platform limit.",
|
|
125
|
+
socket_path=fallback,
|
|
126
|
+
remediation=("Set AGENT_BRAIN_UDS_PATH to a path shorter than 104 bytes."),
|
|
127
|
+
)
|
|
128
|
+
return fallback
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _read_pointer_file(pointer: Path) -> Path | None:
|
|
132
|
+
"""Return the validated socket path from ``pointer`` or ``None``.
|
|
133
|
+
|
|
134
|
+
Defenses (plan §8 — Phase 5):
|
|
135
|
+
|
|
136
|
+
* ``os.lstat`` instead of ``Path.is_file`` so a symlink at the
|
|
137
|
+
pointer path is detected, not silently followed.
|
|
138
|
+
* Pointer must be a regular file; symlinks / sockets / fifos / dirs
|
|
139
|
+
are rejected outright.
|
|
140
|
+
* Pointer contents must be an absolute path with no embedded null
|
|
141
|
+
bytes.
|
|
142
|
+
|
|
143
|
+
``validate_socket(...)`` still runs on the returned path before any
|
|
144
|
+
connection, so this is defense-in-depth — the goal is to fail fast
|
|
145
|
+
on obviously attacker-controlled inputs rather than to substitute
|
|
146
|
+
for the socket-level checks.
|
|
147
|
+
"""
|
|
148
|
+
try:
|
|
149
|
+
pst = os.lstat(pointer)
|
|
150
|
+
except FileNotFoundError:
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
if stat.S_ISLNK(pst.st_mode):
|
|
154
|
+
raise SocketPermissionError(
|
|
155
|
+
"Refusing to follow symlinked pointer file.",
|
|
156
|
+
socket_path=pointer,
|
|
157
|
+
remediation=(
|
|
158
|
+
f"Delete {pointer} (it is a symlink) and re-bind the "
|
|
159
|
+
"server, which will write a real pointer file."
|
|
160
|
+
),
|
|
161
|
+
)
|
|
162
|
+
if not stat.S_ISREG(pst.st_mode):
|
|
163
|
+
raise SocketPermissionError(
|
|
164
|
+
"Pointer file is not a regular file.",
|
|
165
|
+
socket_path=pointer,
|
|
166
|
+
remediation=f"Delete {pointer} and re-bind the server.",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
raw = pointer.read_bytes()
|
|
170
|
+
if b"\x00" in raw:
|
|
171
|
+
raise SocketPermissionError(
|
|
172
|
+
"Pointer file contains an embedded null byte; refusing to parse.",
|
|
173
|
+
socket_path=pointer,
|
|
174
|
+
remediation=f"Delete {pointer} and re-bind the server.",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
target_str = raw.decode("utf-8", errors="strict").strip()
|
|
178
|
+
target = Path(target_str)
|
|
179
|
+
if not target.is_absolute():
|
|
180
|
+
raise SocketPermissionError(
|
|
181
|
+
f"Pointer-file target {target_str!r} is not an absolute path.",
|
|
182
|
+
socket_path=target,
|
|
183
|
+
remediation=(
|
|
184
|
+
f"Delete {pointer} and re-bind the server (or set "
|
|
185
|
+
"AGENT_BRAIN_UDS_PATH to an absolute path under 104 bytes)."
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
return target
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def write_pointer_file(state_dir: Path, real_socket_path: Path) -> Path:
|
|
192
|
+
"""Write the pointer file used by long-path fallback.
|
|
193
|
+
|
|
194
|
+
Called by the server when binding to a ``/tmp`` fallback socket so
|
|
195
|
+
later clients can discover the real socket without recomputing.
|
|
196
|
+
"""
|
|
197
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
198
|
+
pointer = state_dir / POINTER_FILE_NAME
|
|
199
|
+
pointer.write_text(str(real_socket_path))
|
|
200
|
+
return pointer
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Socket permission validation for agent-brain-uds.
|
|
2
|
+
|
|
3
|
+
Filesystem permissions are the auth model for local UDS: a socket file
|
|
4
|
+
that is mode 0600 owned by the current user is reachable only by that
|
|
5
|
+
user. We enforce that explicitly on connect so a symlink-hijack or a
|
|
6
|
+
world-readable socket fails loud instead of leaking traffic.
|
|
7
|
+
|
|
8
|
+
Adversarial test coverage lands in Phase 5 (plan §13). Phase 1 ships
|
|
9
|
+
the happy-path and basic rejection logic that those tests build on.
|
|
10
|
+
|
|
11
|
+
See docs/plans/2026-05-28-mcp-uds-transport-design.md §6.5 / §8.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import stat
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .errors import SocketNotFoundError, SocketPermissionError
|
|
21
|
+
|
|
22
|
+
#: Mode bits we refuse to accept on the socket file (group + world rwx).
|
|
23
|
+
FORBIDDEN_SOCKET_BITS = stat.S_IRWXG | stat.S_IRWXO # 0o077
|
|
24
|
+
|
|
25
|
+
#: Mode we require on the parent directory of the socket file.
|
|
26
|
+
REQUIRED_PARENT_DIR_MODE = 0o700
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def validate_socket(path: Path) -> None:
|
|
30
|
+
"""Validate that a UDS socket file is safe for the current user to connect.
|
|
31
|
+
|
|
32
|
+
Checks (in order):
|
|
33
|
+
|
|
34
|
+
1. The path is not a symlink (``os.lstat``).
|
|
35
|
+
2. The path exists.
|
|
36
|
+
3. The path is a socket file (``S_ISSOCK``).
|
|
37
|
+
4. The path is owned by the current UID.
|
|
38
|
+
5. The path has no group or world permission bits set.
|
|
39
|
+
6. The parent directory is mode ``0700``.
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
SocketNotFoundError: when the path does not exist.
|
|
43
|
+
SocketPermissionError: when any safety check fails.
|
|
44
|
+
"""
|
|
45
|
+
# Use lstat to detect symlinks without following them. A symlink at
|
|
46
|
+
# the socket path is a classic privilege-escalation hook.
|
|
47
|
+
try:
|
|
48
|
+
st = os.lstat(path)
|
|
49
|
+
except FileNotFoundError as exc:
|
|
50
|
+
raise SocketNotFoundError(
|
|
51
|
+
f"No socket file at {path}.",
|
|
52
|
+
socket_path=path,
|
|
53
|
+
remediation=(
|
|
54
|
+
"Start the server with `agent-brain start --uds`, or set "
|
|
55
|
+
"AGENT_BRAIN_UDS_PATH to point at an existing socket."
|
|
56
|
+
),
|
|
57
|
+
) from exc
|
|
58
|
+
|
|
59
|
+
if stat.S_ISLNK(st.st_mode):
|
|
60
|
+
raise SocketPermissionError(
|
|
61
|
+
"Refusing to connect: socket path is a symlink.",
|
|
62
|
+
socket_path=path,
|
|
63
|
+
remediation=(
|
|
64
|
+
"Inspect the symlink target; if expected, delete the symlink "
|
|
65
|
+
"and re-bind the server, which will create a real socket file."
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if not stat.S_ISSOCK(st.st_mode):
|
|
70
|
+
raise SocketPermissionError(
|
|
71
|
+
"Refusing to connect: path exists but is not a socket file.",
|
|
72
|
+
socket_path=path,
|
|
73
|
+
remediation=(
|
|
74
|
+
"Delete or move the path and re-bind the server to recreate "
|
|
75
|
+
"the socket cleanly."
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
current_uid = os.getuid()
|
|
80
|
+
if st.st_uid != current_uid:
|
|
81
|
+
raise SocketPermissionError(
|
|
82
|
+
f"Refusing to connect: socket owned by uid {st.st_uid}, "
|
|
83
|
+
f"current uid is {current_uid}.",
|
|
84
|
+
socket_path=path,
|
|
85
|
+
remediation=(
|
|
86
|
+
"Confirm you started the server as the current user, or "
|
|
87
|
+
"use sudo -u to run as the socket owner."
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if st.st_mode & FORBIDDEN_SOCKET_BITS:
|
|
92
|
+
raise SocketPermissionError(
|
|
93
|
+
f"Refusing to connect: socket mode {stat.S_IMODE(st.st_mode):#o} "
|
|
94
|
+
"includes group or world bits.",
|
|
95
|
+
socket_path=path,
|
|
96
|
+
remediation=(
|
|
97
|
+
"Re-bind the server (the bind helper chmod 0600s the socket); "
|
|
98
|
+
"or chmod 600 the existing socket if the server is trustworthy."
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
parent_st = os.lstat(path.parent)
|
|
103
|
+
parent_mode = stat.S_IMODE(parent_st.st_mode)
|
|
104
|
+
if parent_mode != REQUIRED_PARENT_DIR_MODE:
|
|
105
|
+
raise SocketPermissionError(
|
|
106
|
+
f"Refusing to connect: parent directory mode is {parent_mode:#o}, "
|
|
107
|
+
f"expected {REQUIRED_PARENT_DIR_MODE:#o}.",
|
|
108
|
+
socket_path=path,
|
|
109
|
+
remediation=(f"chmod 700 {path.parent} and re-bind the server."),
|
|
110
|
+
)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "agent-brain-uds"
|
|
3
|
+
version = "10.1.2"
|
|
4
|
+
description = "Agent Brain UDS - Unix-domain-socket transport for Agent Brain (socket path resolution, permission validation, httpx UDS client factory)"
|
|
5
|
+
authors = ["Spillwave Solutions"]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
homepage = "https://github.com/SpillwaveSolutions/agent-brain"
|
|
9
|
+
repository = "https://github.com/SpillwaveSolutions/agent-brain"
|
|
10
|
+
documentation = "https://github.com/SpillwaveSolutions/agent-brain/wiki"
|
|
11
|
+
keywords = ["agent-brain", "uds", "unix-domain-socket", "transport", "httpx", "ipc"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Environment :: Console",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Operating System :: POSIX",
|
|
21
|
+
]
|
|
22
|
+
packages = [{include = "agent_brain_uds"}]
|
|
23
|
+
|
|
24
|
+
[tool.poetry.dependencies]
|
|
25
|
+
python = "^3.10"
|
|
26
|
+
httpx = "^0.28.0"
|
|
27
|
+
# agent-brain-rag dependency added in Phase 1 when models are imported.
|
|
28
|
+
|
|
29
|
+
[tool.poetry.group.dev.dependencies]
|
|
30
|
+
pytest = "^8.3.0"
|
|
31
|
+
pytest-asyncio = "^0.24.0"
|
|
32
|
+
pytest-cov = "^6.0.0"
|
|
33
|
+
black = "^24.10.0"
|
|
34
|
+
ruff = "^0.8.0"
|
|
35
|
+
mypy = "^1.13.0"
|
|
36
|
+
# Roundtrip test spawns a minimal ASGI app bound to a UDS socket via uvicorn.
|
|
37
|
+
uvicorn = "^0.32.0"
|
|
38
|
+
starlette = "^0.41.0"
|
|
39
|
+
|
|
40
|
+
[build-system]
|
|
41
|
+
requires = ["poetry-core"]
|
|
42
|
+
build-backend = "poetry.core.masonry.api"
|
|
43
|
+
|
|
44
|
+
[tool.black]
|
|
45
|
+
line-length = 88
|
|
46
|
+
target-version = ['py310']
|
|
47
|
+
|
|
48
|
+
[tool.ruff]
|
|
49
|
+
line-length = 88
|
|
50
|
+
|
|
51
|
+
[tool.ruff.lint]
|
|
52
|
+
select = ["E", "F", "W", "I", "N", "UP", "B", "C4"]
|
|
53
|
+
|
|
54
|
+
[tool.mypy]
|
|
55
|
+
python_version = "3.10"
|
|
56
|
+
strict = true
|
|
57
|
+
ignore_missing_imports = true
|
|
58
|
+
|
|
59
|
+
[tool.pytest.ini_options]
|
|
60
|
+
testpaths = ["tests"]
|
|
61
|
+
asyncio_mode = "auto"
|