agentconnect-cli 0.1.0a1__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.
- agentconnect/__init__.py +3 -0
- agentconnect/a2a_adapter/__init__.py +6 -0
- agentconnect/a2a_adapter/adapter.py +565 -0
- agentconnect/a2a_adapter/cards.py +149 -0
- agentconnect/a2a_adapter/convert.py +157 -0
- agentconnect/a2a_adapter/facade.py +560 -0
- agentconnect/a2a_adapter/seen.py +49 -0
- agentconnect/a2a_adapter/serve.py +67 -0
- agentconnect/adapter.py +319 -0
- agentconnect/adapters.py +52 -0
- agentconnect/apiclient.py +51 -0
- agentconnect/backoff.py +42 -0
- agentconnect/callback.py +314 -0
- agentconnect/cli.py +2324 -0
- agentconnect/cli_json.py +470 -0
- agentconnect/cli_support.py +444 -0
- agentconnect/clock.py +161 -0
- agentconnect/config.py +1213 -0
- agentconnect/daemon/__init__.py +41 -0
- agentconnect/deliveries.py +295 -0
- agentconnect/directory_client.py +255 -0
- agentconnect/failpoints.py +99 -0
- agentconnect/identity.py +111 -0
- agentconnect/isolation.py +345 -0
- agentconnect/keystore.py +279 -0
- agentconnect/limits.py +457 -0
- agentconnect/log.py +143 -0
- agentconnect/mcp/__init__.py +30 -0
- agentconnect/mcp/backend.py +405 -0
- agentconnect/mcp/server.py +382 -0
- agentconnect/mcp/session.py +373 -0
- agentconnect/mcp/tools.py +583 -0
- agentconnect/ownerfile.py +238 -0
- agentconnect/payloads.py +109 -0
- agentconnect/policy.py +1066 -0
- agentconnect/provision.py +239 -0
- agentconnect/py.typed +0 -0
- agentconnect/relay.py +405 -0
- agentconnect/runtime/__init__.py +46 -0
- agentconnect/runtime/agent.py +225 -0
- agentconnect/runtime/api.py +2043 -0
- agentconnect/runtime/engine.py +5359 -0
- agentconnect/runtime/errors.py +47 -0
- agentconnect/runtime/floor.py +649 -0
- agentconnect/runtime/groups.py +1351 -0
- agentconnect/runtime/knocks.py +1422 -0
- agentconnect/runtime/ledger.py +94 -0
- agentconnect/runtime/listing.py +1307 -0
- agentconnect/runtime/registry.py +53 -0
- agentconnect/runtime/runtime.py +158 -0
- agentconnect/runtime/sas.py +1002 -0
- agentconnect/sas_emoji.py +83 -0
- agentconnect/schemas.py +381 -0
- agentconnect/shapes.py +56 -0
- agentconnect/sockets.py +86 -0
- agentconnect/sse.py +234 -0
- agentconnect/store.py +3282 -0
- agentconnect/tasks.py +35 -0
- agentconnect/tokens.py +63 -0
- agentconnect/webhook.py +199 -0
- agentconnect_cli-0.1.0a1.dist-info/METADATA +103 -0
- agentconnect_cli-0.1.0a1.dist-info/RECORD +64 -0
- agentconnect_cli-0.1.0a1.dist-info/WHEEL +4 -0
- agentconnect_cli-0.1.0a1.dist-info/entry_points.txt +3 -0
agentconnect/identity.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""The daemon's identity, its instance, and the core MLS client.
|
|
2
|
+
|
|
3
|
+
One identity per agent and one instance per daemon (SPEC §2). Both are
|
|
4
|
+
created through the core bindings on first start and kept in the
|
|
5
|
+
:mod:`keystore`; nothing here ever logs or prints them.
|
|
6
|
+
|
|
7
|
+
Residual exposure: the identity secret, the instance (with its signing
|
|
8
|
+
key) and ``db_key`` are handled here as Python ``bytes`` to store them and
|
|
9
|
+
to open the core client, so they stay in the Python heap, which can't be
|
|
10
|
+
zeroized, for the life of the process. Signing itself happens in the core.
|
|
11
|
+
|
|
12
|
+
The core's MLS store is a SQLCipher database encrypted under a 32-byte
|
|
13
|
+
``db_key``, created on first start and kept in the keystore next to the
|
|
14
|
+
identity. :func:`open_core_client` is the one place that key reaches the
|
|
15
|
+
core. The data directory is 0700 and every database file 0600 on top.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import secrets
|
|
22
|
+
from collections.abc import Callable, Iterable
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
import agentconnect_core as ac
|
|
27
|
+
from agentconnect.keystore import Keystore, KeystoreError, private_file
|
|
28
|
+
|
|
29
|
+
DB_KEY_LEN = ac.DB_KEY_LEN
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Seeded:
|
|
34
|
+
"""Seeded randomness for tests (guessable keys: never in production)."""
|
|
35
|
+
|
|
36
|
+
seed: int
|
|
37
|
+
|
|
38
|
+
def derive(self, label: str) -> int:
|
|
39
|
+
digest = hashlib.sha256(f"{self.seed}:{label}".encode()).digest()
|
|
40
|
+
return int.from_bytes(digest[:8], "big")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _load_or_create(store: Keystore, name: str, make: Callable[[], bytes]) -> bytes:
|
|
44
|
+
value = store.get(name)
|
|
45
|
+
if value is None:
|
|
46
|
+
value = make()
|
|
47
|
+
store.put(name, value)
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_identity(store: Keystore, testing: Seeded | None = None) -> ac.Identity:
|
|
52
|
+
def make() -> bytes:
|
|
53
|
+
ident = (
|
|
54
|
+
ac.Identity._generate_for_testing(testing.derive("identity"))
|
|
55
|
+
if testing
|
|
56
|
+
else ac.Identity.generate()
|
|
57
|
+
)
|
|
58
|
+
return ident.secret_bytes()
|
|
59
|
+
|
|
60
|
+
return ac.Identity.from_secret_bytes(_load_or_create(store, "identity", make))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load_instance(
|
|
64
|
+
store: Keystore, identity: ac.Identity, testing: Seeded | None = None
|
|
65
|
+
) -> ac.Instance:
|
|
66
|
+
def make() -> bytes:
|
|
67
|
+
inst = (
|
|
68
|
+
identity._create_instance_for_testing(seed=testing.derive("instance"))
|
|
69
|
+
if testing
|
|
70
|
+
else identity.create_instance()
|
|
71
|
+
)
|
|
72
|
+
return inst.to_secret_bytes()
|
|
73
|
+
|
|
74
|
+
instance = ac.Instance.from_secret_bytes(_load_or_create(store, "instance", make))
|
|
75
|
+
if instance.identity != identity.id:
|
|
76
|
+
msg = "stored instance belongs to another identity"
|
|
77
|
+
raise ValueError(msg)
|
|
78
|
+
return instance
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def load_db_key(store: Keystore, encrypted: Iterable[Path] = ()) -> bytes:
|
|
82
|
+
"""The database key, created on first use.
|
|
83
|
+
|
|
84
|
+
encrypted lists the files the key protects. If any of them already
|
|
85
|
+
has content, a missing key is an error: a fresh key could never open
|
|
86
|
+
them, and writing it would only hide the problem.
|
|
87
|
+
"""
|
|
88
|
+
value = store.get("db_key")
|
|
89
|
+
if value is not None:
|
|
90
|
+
return value
|
|
91
|
+
if any(p.exists() and p.stat().st_size > 0 for p in encrypted):
|
|
92
|
+
msg = "the database key is missing from the keystore; the encrypted stores can't be opened"
|
|
93
|
+
raise KeystoreError(msg)
|
|
94
|
+
return _load_or_create(store, "db_key", lambda: secrets.token_bytes(DB_KEY_LEN))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def open_core_client(
|
|
98
|
+
path: Path,
|
|
99
|
+
instance: ac.Instance,
|
|
100
|
+
db_key: bytes,
|
|
101
|
+
testing: Seeded | None = None,
|
|
102
|
+
) -> ac.Client:
|
|
103
|
+
"""The core client over its encrypted SQLite file (created 0600)."""
|
|
104
|
+
if len(db_key) != DB_KEY_LEN:
|
|
105
|
+
msg = "db key must be 32 bytes"
|
|
106
|
+
raise ValueError(msg)
|
|
107
|
+
private_file(path)
|
|
108
|
+
secret = instance.to_secret_bytes()
|
|
109
|
+
if testing:
|
|
110
|
+
return ac.Client._for_testing(str(path), secret, testing.derive("client"), db_key=db_key)
|
|
111
|
+
return ac.Client(str(path), secret, db_key=db_key)
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Separate-OS-user mode: the daemon under a user of its own (``docs/isolation.md``).
|
|
2
|
+
|
|
3
|
+
Code running as the daemon's OS user can read every file the daemon can,
|
|
4
|
+
``human-token`` included (ADR-005, Decision 4). With ``isolation.enabled`` the
|
|
5
|
+
daemon runs as its own user (``agentconnect``) and agents run as other users:
|
|
6
|
+
|
|
7
|
+
* The data directory (keys, databases, both tokens) stays private to the
|
|
8
|
+
daemon's user, 0700 and 0600. The daemon refuses to start when it, a token
|
|
9
|
+
or the agent directory is looser, when it runs as root, or when the API is
|
|
10
|
+
on TCP (a TCP peer has no credentials to check).
|
|
11
|
+
* Agents use a separate **agent directory** (``isolation.agent_dir``, owned by
|
|
12
|
+
the daemon's user and ``isolation.group``, no access for others). It holds
|
|
13
|
+
only agent-scope material: the API socket (0660) and copies of ``api-token``
|
|
14
|
+
and ``webhook-secret`` (0640). Agents point ``AC_DATA_DIR`` at it.
|
|
15
|
+
* Every connection's peer UID comes from the kernel (``SO_PEERCRED`` on
|
|
16
|
+
Linux, ``getpeereid(2)`` on macOS and the BSDs, which reads
|
|
17
|
+
``LOCAL_PEERCRED``). A UID that is not the daemon's own, an agent user or
|
|
18
|
+
a human user is logged and refused before the request is read.
|
|
19
|
+
* The human token is accepted only from the daemon's UID or a configured
|
|
20
|
+
human UID. This is on top of the token, not instead of it.
|
|
21
|
+
|
|
22
|
+
The human reaches the human-only routes with ``sudo -u agentconnect
|
|
23
|
+
agentconnect ... --config /etc/agentconnect/config.toml``: the CLI then runs
|
|
24
|
+
as the daemon's user, reads ``human-token`` from the data directory and
|
|
25
|
+
connects with the daemon's UID. There is no second socket and no second copy
|
|
26
|
+
of the human token.
|
|
27
|
+
|
|
28
|
+
:class:`PeerGate` is the hook the API server calls; it is disabled (every
|
|
29
|
+
check passes) unless the mode is on. The credential provider is injectable,
|
|
30
|
+
so tests need no second OS user.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import contextlib
|
|
36
|
+
import ctypes
|
|
37
|
+
import grp
|
|
38
|
+
import os
|
|
39
|
+
import pwd
|
|
40
|
+
import socket
|
|
41
|
+
import stat
|
|
42
|
+
import struct
|
|
43
|
+
import tempfile
|
|
44
|
+
from collections.abc import Callable, Iterable
|
|
45
|
+
from contextvars import ContextVar
|
|
46
|
+
from dataclasses import dataclass
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
from typing import Any, Protocol
|
|
49
|
+
|
|
50
|
+
from agentconnect import log
|
|
51
|
+
from agentconnect.config import Config
|
|
52
|
+
from agentconnect.sockets import MAX_SOCKET_PATH, too_long
|
|
53
|
+
|
|
54
|
+
LOG = log.get("agentconnect.isolation")
|
|
55
|
+
|
|
56
|
+
# Files in the data directory that must stay private to the daemon's user.
|
|
57
|
+
PRIVATE_FILES = ("api-token", "human-token", "webhook-secret")
|
|
58
|
+
# Agent-scope files copied into the agent directory. Never the human token.
|
|
59
|
+
AGENT_FILES = ("api-token", "webhook-secret")
|
|
60
|
+
SOCKET_MODE = 0o660
|
|
61
|
+
AGENT_FILE_MODE = 0o640
|
|
62
|
+
AGENT_DIR_MODE = 0o750
|
|
63
|
+
|
|
64
|
+
_UCRED = struct.Struct("=iII") # Linux struct ucred: pid_t pid, uid_t uid, gid_t gid
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class IsolationError(RuntimeError):
|
|
68
|
+
"""The separate-OS-user mode can't start safely with this setup."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class PeerCred:
|
|
73
|
+
"""Who is on the other end of a Unix socket, as the kernel reports it."""
|
|
74
|
+
|
|
75
|
+
uid: int
|
|
76
|
+
gid: int
|
|
77
|
+
pid: int | None = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
CredentialProvider = Callable[[Any], PeerCred]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def getpeereid(fd: int, libc: Any = None) -> PeerCred:
|
|
84
|
+
"""``getpeereid(2)``: macOS and the BSDs (built on ``LOCAL_PEERCRED``)."""
|
|
85
|
+
if libc is None:
|
|
86
|
+
libc = ctypes.CDLL(None, use_errno=True)
|
|
87
|
+
uid, gid = ctypes.c_uint32(), ctypes.c_uint32()
|
|
88
|
+
if libc.getpeereid(fd, ctypes.byref(uid), ctypes.byref(gid)) != 0:
|
|
89
|
+
err = ctypes.get_errno()
|
|
90
|
+
raise OSError(err, os.strerror(err))
|
|
91
|
+
return PeerCred(uid=uid.value, gid=gid.value)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def peer_credentials(sock: Any) -> PeerCred:
|
|
95
|
+
"""The peer's credentials for a connected Unix socket. ``SO_PEERCRED``
|
|
96
|
+
where the platform has it (Linux), ``getpeereid(2)`` otherwise."""
|
|
97
|
+
so_peercred = getattr(socket, "SO_PEERCRED", None)
|
|
98
|
+
if so_peercred is not None:
|
|
99
|
+
raw = sock.getsockopt(socket.SOL_SOCKET, so_peercred, _UCRED.size)
|
|
100
|
+
pid, uid, gid = _UCRED.unpack(raw)
|
|
101
|
+
return PeerCred(uid=uid, gid=gid, pid=pid)
|
|
102
|
+
return getpeereid(sock.fileno())
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def resolve_uid(user: str) -> int:
|
|
106
|
+
"""A user name or a decimal UID as a UID."""
|
|
107
|
+
if user.isdigit():
|
|
108
|
+
return int(user)
|
|
109
|
+
try:
|
|
110
|
+
return pwd.getpwnam(user).pw_uid
|
|
111
|
+
except KeyError:
|
|
112
|
+
msg = f"isolation: no such user {user!r}"
|
|
113
|
+
raise IsolationError(msg) from None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def resolve_gid(group: str) -> int:
|
|
117
|
+
"""A group name or a decimal GID as a GID."""
|
|
118
|
+
if group.isdigit():
|
|
119
|
+
return int(group)
|
|
120
|
+
try:
|
|
121
|
+
return grp.getgrnam(group).gr_gid
|
|
122
|
+
except KeyError:
|
|
123
|
+
msg = f"isolation: no such group {group!r}"
|
|
124
|
+
raise IsolationError(msg) from None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _lstat(path: Path) -> os.stat_result | None:
|
|
128
|
+
try:
|
|
129
|
+
return path.lstat()
|
|
130
|
+
except FileNotFoundError:
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def check_private(path: Path, euid: int, *, directory: bool) -> None:
|
|
135
|
+
"""``path``, if it exists, is a real directory (or regular file) owned by
|
|
136
|
+
``euid`` with no group or other bits. Raises :class:`IsolationError`."""
|
|
137
|
+
st = _lstat(path)
|
|
138
|
+
if st is None:
|
|
139
|
+
return
|
|
140
|
+
kind_ok = stat.S_ISDIR(st.st_mode) if directory else stat.S_ISREG(st.st_mode)
|
|
141
|
+
want = "0700" if directory else "0600"
|
|
142
|
+
if not kind_ok:
|
|
143
|
+
msg = f"{path} must be a {'directory' if directory else 'regular file'}, not a link"
|
|
144
|
+
raise IsolationError(msg)
|
|
145
|
+
if st.st_uid != euid or st.st_mode & 0o077:
|
|
146
|
+
msg = f"{path} must be owned by the daemon's user (UID {euid}) with mode {want}"
|
|
147
|
+
raise IsolationError(msg)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def check_data_dir(data_dir: Path, euid: int) -> None:
|
|
151
|
+
"""The data directory and its secret files are private to the daemon's
|
|
152
|
+
user. A missing directory is fine: the daemon creates it 0700."""
|
|
153
|
+
check_private(data_dir, euid, directory=True)
|
|
154
|
+
for name in PRIVATE_FILES:
|
|
155
|
+
check_private(data_dir / name, euid, directory=False)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def check_agent_dir(agent_dir: Path, euid: int, gid: int) -> None:
|
|
159
|
+
"""The agent directory exists (it is created if its parent allows), is
|
|
160
|
+
owned by the daemon's user and the agent group, and gives others nothing."""
|
|
161
|
+
st = _lstat(agent_dir)
|
|
162
|
+
if st is None:
|
|
163
|
+
try:
|
|
164
|
+
agent_dir.mkdir(mode=0o700)
|
|
165
|
+
os.chown(agent_dir, -1, gid)
|
|
166
|
+
agent_dir.chmod(AGENT_DIR_MODE)
|
|
167
|
+
except OSError as e:
|
|
168
|
+
msg = f"cannot create {agent_dir}: {e.strerror}; create it (see docs/isolation.md)"
|
|
169
|
+
raise IsolationError(msg) from None
|
|
170
|
+
st = agent_dir.lstat()
|
|
171
|
+
if not stat.S_ISDIR(st.st_mode):
|
|
172
|
+
msg = f"{agent_dir} must be a directory, not a link"
|
|
173
|
+
raise IsolationError(msg)
|
|
174
|
+
if st.st_uid != euid or st.st_gid != gid:
|
|
175
|
+
msg = f"{agent_dir} must be owned by UID {euid} and GID {gid}"
|
|
176
|
+
raise IsolationError(msg)
|
|
177
|
+
if st.st_mode & 0o007 or not st.st_mode & 0o010:
|
|
178
|
+
msg = f"{agent_dir} must have mode 0750 or 0710 (group access, none for others)"
|
|
179
|
+
raise IsolationError(msg)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _inside(a: Path, b: Path) -> bool:
|
|
183
|
+
a, b = a.resolve(), b.resolve()
|
|
184
|
+
return a == b or a.is_relative_to(b) or b.is_relative_to(a)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def check_config(cfg: Config, euid: int, agent_uids: set[int], human_uids: set[int]) -> None:
|
|
188
|
+
"""The parts of the setup that don't need the filesystem."""
|
|
189
|
+
if euid == 0:
|
|
190
|
+
msg = "isolation: don't run the daemon as root; run it as its own user"
|
|
191
|
+
raise IsolationError(msg)
|
|
192
|
+
if cfg.api_tcp_port:
|
|
193
|
+
msg = "isolation needs the Unix socket: a TCP peer's user can't be checked"
|
|
194
|
+
raise IsolationError(msg)
|
|
195
|
+
if agent_uids & human_uids:
|
|
196
|
+
msg = "isolation: a user can't be both an agent user and a human user"
|
|
197
|
+
raise IsolationError(msg)
|
|
198
|
+
if euid in agent_uids:
|
|
199
|
+
msg = "isolation: the daemon's own user can't be an agent user"
|
|
200
|
+
raise IsolationError(msg)
|
|
201
|
+
if too_long(cfg.socket_path):
|
|
202
|
+
# Agents' users reach the socket in the agent directory; a short
|
|
203
|
+
# private fallback directory would lock them out.
|
|
204
|
+
msg = (
|
|
205
|
+
f"isolation: the socket path {cfg.socket_path} is longer than "
|
|
206
|
+
f"{MAX_SOCKET_PATH} bytes, the most a Unix socket path can be; "
|
|
207
|
+
"use a shorter isolation.agent_dir"
|
|
208
|
+
)
|
|
209
|
+
raise IsolationError(msg)
|
|
210
|
+
if cfg.socket_path.parent != cfg.isolation_agent_dir:
|
|
211
|
+
msg = "isolation: api.socket must be in isolation.agent_dir"
|
|
212
|
+
raise IsolationError(msg)
|
|
213
|
+
if _inside(cfg.isolation_agent_dir, cfg.data_dir):
|
|
214
|
+
msg = "isolation: agent_dir and data_dir must not contain each other"
|
|
215
|
+
raise IsolationError(msg)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _write_shared(path: Path, data: bytes, gid: int) -> None:
|
|
219
|
+
"""Atomically replace ``path`` with ``data``, mode 0640, group ``gid``.
|
|
220
|
+
The temporary file is created 0600 and widened only after the chown."""
|
|
221
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
|
|
222
|
+
try:
|
|
223
|
+
try:
|
|
224
|
+
os.fchown(fd, -1, gid)
|
|
225
|
+
os.fchmod(fd, AGENT_FILE_MODE)
|
|
226
|
+
view = memoryview(data)
|
|
227
|
+
while view:
|
|
228
|
+
view = view[os.write(fd, view) :]
|
|
229
|
+
os.fsync(fd)
|
|
230
|
+
finally:
|
|
231
|
+
os.close(fd)
|
|
232
|
+
os.replace(tmp, path)
|
|
233
|
+
except BaseException:
|
|
234
|
+
Path(tmp).unlink(missing_ok=True)
|
|
235
|
+
raise
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class _Transport(Protocol):
|
|
239
|
+
def get_extra_info(self, name: str, default: Any = None) -> Any: ...
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# The peer of the connection being served. Each connection is its own task,
|
|
243
|
+
# so the value never leaks from one connection to another.
|
|
244
|
+
_PEER: ContextVar[PeerCred | None] = ContextVar("agentconnect_api_peer", default=None)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class PeerGate:
|
|
248
|
+
"""The API server's peer-credential checks. Every check passes when the
|
|
249
|
+
separate-OS-user mode is off (the socket is 0600 then)."""
|
|
250
|
+
|
|
251
|
+
def __init__( # noqa: PLR0913 - keyword-only settings
|
|
252
|
+
self,
|
|
253
|
+
*,
|
|
254
|
+
enabled: bool = False,
|
|
255
|
+
runtime_uid: int = -1,
|
|
256
|
+
agent_uids: Iterable[int] = (),
|
|
257
|
+
human_uids: Iterable[int] = (),
|
|
258
|
+
gid: int = -1,
|
|
259
|
+
data_dir: Path | None = None,
|
|
260
|
+
agent_dir: Path | None = None,
|
|
261
|
+
credentials: CredentialProvider = peer_credentials,
|
|
262
|
+
) -> None:
|
|
263
|
+
self.enabled = enabled
|
|
264
|
+
self.runtime_uid = runtime_uid
|
|
265
|
+
self.gid = gid
|
|
266
|
+
self.data_dir, self.agent_dir = data_dir, agent_dir
|
|
267
|
+
self._human = frozenset({runtime_uid, *human_uids})
|
|
268
|
+
self._allowed = self._human | frozenset(agent_uids)
|
|
269
|
+
self._credentials = credentials
|
|
270
|
+
|
|
271
|
+
@classmethod
|
|
272
|
+
def from_config(
|
|
273
|
+
cls,
|
|
274
|
+
cfg: Config,
|
|
275
|
+
*,
|
|
276
|
+
credentials: CredentialProvider = peer_credentials,
|
|
277
|
+
euid: int | None = None,
|
|
278
|
+
) -> PeerGate:
|
|
279
|
+
"""The gate for ``cfg``. With the mode on, first checks the setup and
|
|
280
|
+
raises :class:`IsolationError` if anything is unsafe."""
|
|
281
|
+
if not cfg.isolation:
|
|
282
|
+
return cls()
|
|
283
|
+
euid = os.geteuid() if euid is None else euid
|
|
284
|
+
agent_uids = {resolve_uid(u) for u in cfg.isolation_agent_users}
|
|
285
|
+
human_uids = {resolve_uid(u) for u in cfg.isolation_human_users}
|
|
286
|
+
gid = resolve_gid(cfg.isolation_group)
|
|
287
|
+
check_config(cfg, euid, agent_uids, human_uids)
|
|
288
|
+
check_data_dir(cfg.data_dir, euid)
|
|
289
|
+
check_agent_dir(cfg.isolation_agent_dir, euid, gid)
|
|
290
|
+
return cls(
|
|
291
|
+
enabled=True,
|
|
292
|
+
runtime_uid=euid,
|
|
293
|
+
agent_uids=agent_uids,
|
|
294
|
+
human_uids=human_uids,
|
|
295
|
+
gid=gid,
|
|
296
|
+
data_dir=cfg.data_dir,
|
|
297
|
+
agent_dir=cfg.isolation_agent_dir,
|
|
298
|
+
credentials=credentials,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
def admit(self, transport: _Transport) -> bool:
|
|
302
|
+
"""Check the connection's peer. ``False``: refuse it unread."""
|
|
303
|
+
if not self.enabled:
|
|
304
|
+
return True
|
|
305
|
+
sock = transport.get_extra_info("socket")
|
|
306
|
+
try:
|
|
307
|
+
if sock is None:
|
|
308
|
+
raise OSError("no socket")
|
|
309
|
+
cred = self._credentials(sock)
|
|
310
|
+
except OSError:
|
|
311
|
+
LOG.warning("api_peer_unknown")
|
|
312
|
+
return False
|
|
313
|
+
if cred.uid not in self._allowed:
|
|
314
|
+
LOG.warning("api_peer_refused", uid=cred.uid, pid=cred.pid)
|
|
315
|
+
return False
|
|
316
|
+
_PEER.set(cred)
|
|
317
|
+
return True
|
|
318
|
+
|
|
319
|
+
def peer_uid(self) -> int | None:
|
|
320
|
+
"""The current connection's peer UID, read at :meth:`admit`; ``None``
|
|
321
|
+
unless the mode is on (every peer is the daemon's own user then)."""
|
|
322
|
+
cred = _PEER.get() if self.enabled else None
|
|
323
|
+
return None if cred is None else cred.uid
|
|
324
|
+
|
|
325
|
+
def human_allowed(self) -> bool:
|
|
326
|
+
"""Whether the current connection's peer may use the human token."""
|
|
327
|
+
if not self.enabled:
|
|
328
|
+
return True
|
|
329
|
+
cred = _PEER.get()
|
|
330
|
+
if cred is not None and cred.uid in self._human:
|
|
331
|
+
return True
|
|
332
|
+
LOG.warning("api_human_refused", uid=None if cred is None else cred.uid)
|
|
333
|
+
return False
|
|
334
|
+
|
|
335
|
+
def publish(self, socket_path: Path) -> None:
|
|
336
|
+
"""Open the socket to the agent group and copy the agent-scope files
|
|
337
|
+
into the agent directory."""
|
|
338
|
+
if not self.enabled or self.data_dir is None or self.agent_dir is None:
|
|
339
|
+
return
|
|
340
|
+
os.chown(socket_path, -1, self.gid) # group first, then widen the mode
|
|
341
|
+
socket_path.chmod(SOCKET_MODE)
|
|
342
|
+
for name in AGENT_FILES:
|
|
343
|
+
with contextlib.suppress(FileNotFoundError):
|
|
344
|
+
_write_shared(self.agent_dir / name, (self.data_dir / name).read_bytes(), self.gid)
|
|
345
|
+
LOG.info("agent_dir_published")
|