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.
Files changed (64) hide show
  1. agentconnect/__init__.py +3 -0
  2. agentconnect/a2a_adapter/__init__.py +6 -0
  3. agentconnect/a2a_adapter/adapter.py +565 -0
  4. agentconnect/a2a_adapter/cards.py +149 -0
  5. agentconnect/a2a_adapter/convert.py +157 -0
  6. agentconnect/a2a_adapter/facade.py +560 -0
  7. agentconnect/a2a_adapter/seen.py +49 -0
  8. agentconnect/a2a_adapter/serve.py +67 -0
  9. agentconnect/adapter.py +319 -0
  10. agentconnect/adapters.py +52 -0
  11. agentconnect/apiclient.py +51 -0
  12. agentconnect/backoff.py +42 -0
  13. agentconnect/callback.py +314 -0
  14. agentconnect/cli.py +2324 -0
  15. agentconnect/cli_json.py +470 -0
  16. agentconnect/cli_support.py +444 -0
  17. agentconnect/clock.py +161 -0
  18. agentconnect/config.py +1213 -0
  19. agentconnect/daemon/__init__.py +41 -0
  20. agentconnect/deliveries.py +295 -0
  21. agentconnect/directory_client.py +255 -0
  22. agentconnect/failpoints.py +99 -0
  23. agentconnect/identity.py +111 -0
  24. agentconnect/isolation.py +345 -0
  25. agentconnect/keystore.py +279 -0
  26. agentconnect/limits.py +457 -0
  27. agentconnect/log.py +143 -0
  28. agentconnect/mcp/__init__.py +30 -0
  29. agentconnect/mcp/backend.py +405 -0
  30. agentconnect/mcp/server.py +382 -0
  31. agentconnect/mcp/session.py +373 -0
  32. agentconnect/mcp/tools.py +583 -0
  33. agentconnect/ownerfile.py +238 -0
  34. agentconnect/payloads.py +109 -0
  35. agentconnect/policy.py +1066 -0
  36. agentconnect/provision.py +239 -0
  37. agentconnect/py.typed +0 -0
  38. agentconnect/relay.py +405 -0
  39. agentconnect/runtime/__init__.py +46 -0
  40. agentconnect/runtime/agent.py +225 -0
  41. agentconnect/runtime/api.py +2043 -0
  42. agentconnect/runtime/engine.py +5359 -0
  43. agentconnect/runtime/errors.py +47 -0
  44. agentconnect/runtime/floor.py +649 -0
  45. agentconnect/runtime/groups.py +1351 -0
  46. agentconnect/runtime/knocks.py +1422 -0
  47. agentconnect/runtime/ledger.py +94 -0
  48. agentconnect/runtime/listing.py +1307 -0
  49. agentconnect/runtime/registry.py +53 -0
  50. agentconnect/runtime/runtime.py +158 -0
  51. agentconnect/runtime/sas.py +1002 -0
  52. agentconnect/sas_emoji.py +83 -0
  53. agentconnect/schemas.py +381 -0
  54. agentconnect/shapes.py +56 -0
  55. agentconnect/sockets.py +86 -0
  56. agentconnect/sse.py +234 -0
  57. agentconnect/store.py +3282 -0
  58. agentconnect/tasks.py +35 -0
  59. agentconnect/tokens.py +63 -0
  60. agentconnect/webhook.py +199 -0
  61. agentconnect_cli-0.1.0a1.dist-info/METADATA +103 -0
  62. agentconnect_cli-0.1.0a1.dist-info/RECORD +64 -0
  63. agentconnect_cli-0.1.0a1.dist-info/WHEEL +4 -0
  64. agentconnect_cli-0.1.0a1.dist-info/entry_points.txt +3 -0
@@ -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")