arbiter-server 0.9.1.dev1__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.
Files changed (26) hide show
  1. arbiter_server-0.9.1.dev1/PKG-INFO +26 -0
  2. arbiter_server-0.9.1.dev1/pyproject.toml +58 -0
  3. arbiter_server-0.9.1.dev1/setup.cfg +4 -0
  4. arbiter_server-0.9.1.dev1/src/arbiter_server/__init__.py +1 -0
  5. arbiter_server-0.9.1.dev1/src/arbiter_server/__main__.py +5 -0
  6. arbiter_server-0.9.1.dev1/src/arbiter_server/app.py +44 -0
  7. arbiter_server-0.9.1.dev1/src/arbiter_server/artifacts.py +344 -0
  8. arbiter_server-0.9.1.dev1/src/arbiter_server/cli_errors.py +31 -0
  9. arbiter_server-0.9.1.dev1/src/arbiter_server/config.py +231 -0
  10. arbiter_server-0.9.1.dev1/src/arbiter_server/deploy/docker/arbiter-docker +4477 -0
  11. arbiter_server-0.9.1.dev1/src/arbiter_server/deploy/docker/compose.yaml +101 -0
  12. arbiter_server-0.9.1.dev1/src/arbiter_server/file_protection/__init__.py +20 -0
  13. arbiter_server-0.9.1.dev1/src/arbiter_server/file_protection/posix.py +70 -0
  14. arbiter_server-0.9.1.dev1/src/arbiter_server/file_protection/windows.py +379 -0
  15. arbiter_server-0.9.1.dev1/src/arbiter_server/main.py +2843 -0
  16. arbiter_server-0.9.1.dev1/src/arbiter_server/plugins/__init__.py +36 -0
  17. arbiter_server-0.9.1.dev1/src/arbiter_server/py.typed +1 -0
  18. arbiter_server-0.9.1.dev1/src/arbiter_server/services.py +706 -0
  19. arbiter_server-0.9.1.dev1/src/arbiter_server/storage.py +60 -0
  20. arbiter_server-0.9.1.dev1/src/arbiter_server/version.py +135 -0
  21. arbiter_server-0.9.1.dev1/src/arbiter_server.egg-info/PKG-INFO +26 -0
  22. arbiter_server-0.9.1.dev1/src/arbiter_server.egg-info/SOURCES.txt +24 -0
  23. arbiter_server-0.9.1.dev1/src/arbiter_server.egg-info/dependency_links.txt +1 -0
  24. arbiter_server-0.9.1.dev1/src/arbiter_server.egg-info/entry_points.txt +2 -0
  25. arbiter_server-0.9.1.dev1/src/arbiter_server.egg-info/requires.txt +2 -0
  26. arbiter_server-0.9.1.dev1/src/arbiter_server.egg-info/top_level.txt +1 -0
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.4
2
+ Name: arbiter-server
3
+ Version: 0.9.1.dev1
4
+ Summary: Policy-controlled MCP server for agent-accessible services
5
+ Author-email: Omry Yadan <omry@yadan.net>
6
+ Maintainer-email: Omry Yadan <omry@yadan.net>
7
+ License: MIT
8
+ Project-URL: Homepage, https://github.com/omry/arbiter
9
+ Project-URL: Repository, https://github.com/omry/arbiter
10
+ Keywords: agent,policy,mcp,access-control,security
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: <3.15,>=3.10
22
+ Description-Content-Type: text/markdown
23
+ Requires-Dist: hydra-core<2.0,>=1.3
24
+ Requires-Dist: mcp<2.0,>=1.9.4
25
+
26
+ Server package for Arbiter.
@@ -0,0 +1,58 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "arbiter-server"
7
+ version = "0.9.1.dev1"
8
+ description = "Policy-controlled MCP server for agent-accessible services"
9
+ readme = { text = "Server package for Arbiter.", content-type = "text/markdown" }
10
+ requires-python = ">=3.10,<3.15"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "Omry Yadan", email = "omry@yadan.net" },
14
+ ]
15
+ maintainers = [
16
+ { name = "Omry Yadan", email = "omry@yadan.net" },
17
+ ]
18
+ keywords = ["agent", "policy", "mcp", "access-control", "security"]
19
+ classifiers = [
20
+ "Development Status :: 3 - Alpha",
21
+ "Intended Audience :: Developers",
22
+ "License :: OSI Approved :: MIT License",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3.10",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Programming Language :: Python :: 3.14",
29
+ "Topic :: Software Development :: Libraries :: Python Modules",
30
+ ]
31
+ dependencies = [
32
+ "hydra-core>=1.3,<2.0",
33
+ "mcp>=1.9.4,<2.0",
34
+ ]
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/omry/arbiter"
38
+ Repository = "https://github.com/omry/arbiter"
39
+
40
+ [project.scripts]
41
+ arbiter-server = "arbiter_server.main:main"
42
+
43
+ [tool.setuptools]
44
+ package-dir = {"" = "src"}
45
+
46
+ [tool.setuptools.packages.find]
47
+ where = ["src"]
48
+
49
+ [tool.setuptools.package-data]
50
+ "arbiter_server" = ["py.typed", "deploy/docker/*"]
51
+
52
+ [tool.towncrier]
53
+ name = "Arbiter Server"
54
+ package = "arbiter_server"
55
+ package_dir = "src"
56
+ filename = "NEWS.md"
57
+ directory = "newsfragments"
58
+ issue_format = "#{issue}"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ """Arbiter server package."""
@@ -0,0 +1,5 @@
1
+ from .main import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Protocol, cast
4
+
5
+ from .services import RuntimeRegistry
6
+
7
+
8
+ SERVER_TOOL_NAMES = (
9
+ "info",
10
+ "version_info",
11
+ "list_caps",
12
+ "describe_caps",
13
+ "describe_cap",
14
+ "describe_op",
15
+ "run_op",
16
+ )
17
+
18
+
19
+ class AccountSummariesRuntime(Protocol):
20
+ def account_summaries(self) -> dict[str, object]: ...
21
+
22
+
23
+ class ArbiterApp:
24
+ """Server facade over entry-point supplied service runtimes."""
25
+
26
+ def __init__(
27
+ self,
28
+ runtime_registry: RuntimeRegistry,
29
+ ) -> None:
30
+ self.runtime_registry = runtime_registry
31
+
32
+ def tool_names(self) -> list[str]:
33
+ return list(SERVER_TOOL_NAMES)
34
+
35
+ def list_accounts(self) -> dict[str, object]:
36
+ summaries: dict[str, object] = {}
37
+ for service_name, runtime in sorted(self.runtime_registry.items()):
38
+ if not hasattr(runtime, "account_summaries"):
39
+ continue
40
+ summaries[service_name] = cast(
41
+ AccountSummariesRuntime,
42
+ runtime,
43
+ ).account_summaries()
44
+ return summaries
@@ -0,0 +1,344 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime, timezone
5
+ import hashlib
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ import re
10
+ import secrets
11
+ import shutil
12
+ import time
13
+ from urllib.parse import quote
14
+
15
+ from .storage import ensure_private_dir, plugin_data_dir
16
+
17
+
18
+ DEFAULT_IDLE_TTL_SECONDS = 10 * 60
19
+ DEFAULT_RETENTION_SECONDS = 24 * 60 * 60
20
+ ARTIFACT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
21
+ CONSUMED_MARKER = "consumed"
22
+
23
+
24
+ class ArtifactError(RuntimeError):
25
+ pass
26
+
27
+
28
+ class ArtifactNotFound(ArtifactError):
29
+ pass
30
+
31
+
32
+ class ArtifactExpired(ArtifactError):
33
+ pass
34
+
35
+
36
+ class ArtifactConsumed(ArtifactError):
37
+ pass
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class ArtifactDescriptor:
42
+ id: str
43
+ url: str
44
+ filename: str | None
45
+ content_type: str
46
+ size: int
47
+ sha256: str
48
+ created_at: str
49
+ expires_after_idle_seconds: int
50
+ one_time: bool
51
+
52
+ def to_dict(self) -> dict[str, object]:
53
+ return {
54
+ "id": self.id,
55
+ "url": self.url,
56
+ "filename": self.filename,
57
+ "content_type": self.content_type,
58
+ "size": self.size,
59
+ "sha256": self.sha256,
60
+ "created_at": self.created_at,
61
+ "expires_after_idle_seconds": self.expires_after_idle_seconds,
62
+ "one_time": self.one_time,
63
+ }
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class ArtifactRead:
68
+ path: Path
69
+ filename: str | None
70
+ content_type: str
71
+ size: int
72
+ sha256: str
73
+
74
+
75
+ class PluginArtifactStore:
76
+ def __init__(self, *, plugin: str, store: ArtifactStore) -> None:
77
+ self._plugin = plugin
78
+ self._store = store
79
+
80
+ def create(
81
+ self,
82
+ *,
83
+ content: bytes,
84
+ filename: str | None,
85
+ content_type: str,
86
+ source: dict[str, object],
87
+ ) -> ArtifactDescriptor:
88
+ return self._store.create(
89
+ plugin=self._plugin,
90
+ content=content,
91
+ filename=filename,
92
+ content_type=content_type,
93
+ source=source,
94
+ )
95
+
96
+
97
+ class ArtifactStore:
98
+ def __init__(
99
+ self,
100
+ *,
101
+ root: Path,
102
+ base_url: str,
103
+ idle_ttl_seconds: int = DEFAULT_IDLE_TTL_SECONDS,
104
+ retention_seconds: int = DEFAULT_RETENTION_SECONDS,
105
+ ) -> None:
106
+ if idle_ttl_seconds < 1:
107
+ raise ValueError("artifact idle_ttl_seconds must be at least 1")
108
+ if retention_seconds < 1:
109
+ raise ValueError("artifact retention_seconds must be at least 1")
110
+ self._root = root
111
+ self._base_url = base_url.rstrip("/")
112
+ self._idle_ttl_seconds = idle_ttl_seconds
113
+ self._retention_seconds = retention_seconds
114
+
115
+ @property
116
+ def idle_ttl_seconds(self) -> int:
117
+ return self._idle_ttl_seconds
118
+
119
+ def for_plugin(self, plugin: str) -> PluginArtifactStore:
120
+ return PluginArtifactStore(plugin=plugin, store=self)
121
+
122
+ def create(
123
+ self,
124
+ *,
125
+ plugin: str,
126
+ content: bytes,
127
+ filename: str | None,
128
+ content_type: str,
129
+ source: dict[str, object],
130
+ ) -> ArtifactDescriptor:
131
+ self.purge_expired()
132
+ artifact_id = secrets.token_urlsafe(24)
133
+ nonce = secrets.token_urlsafe(32)
134
+ created_at = time.time()
135
+ payload_hash = hashlib.sha256(content).hexdigest()
136
+ artifact_dir = plugin_data_dir(self._root, plugin) / "artifacts" / artifact_id
137
+ ensure_private_dir(artifact_dir)
138
+ payload_path = artifact_dir / "payload"
139
+ metadata_path = artifact_dir / "metadata.json"
140
+ _write_private_file(payload_path, content)
141
+ metadata = {
142
+ "id": artifact_id,
143
+ "plugin": plugin,
144
+ "filename": filename,
145
+ "content_type": content_type,
146
+ "size": len(content),
147
+ "sha256": payload_hash,
148
+ "source": source,
149
+ "nonce_sha256": hashlib.sha256(nonce.encode("utf-8")).hexdigest(),
150
+ "created_at": created_at,
151
+ "created_at_iso": _iso_timestamp(created_at),
152
+ "last_accessed_at": None,
153
+ "last_accessed_at_iso": None,
154
+ "access_count": 0,
155
+ "consumed": False,
156
+ "one_time": True,
157
+ "idle_ttl_seconds": self._idle_ttl_seconds,
158
+ "retention_seconds": self._retention_seconds,
159
+ }
160
+ _write_private_file(
161
+ metadata_path,
162
+ json.dumps(metadata, sort_keys=True).encode("utf-8"),
163
+ )
164
+ encoded_id = quote(artifact_id, safe="")
165
+ encoded_nonce = quote(nonce, safe="")
166
+ return ArtifactDescriptor(
167
+ id=artifact_id,
168
+ url=f"{self._base_url}/{encoded_id}?nonce={encoded_nonce}",
169
+ filename=filename,
170
+ content_type=content_type,
171
+ size=len(content),
172
+ sha256=payload_hash,
173
+ created_at=str(metadata["created_at_iso"]),
174
+ expires_after_idle_seconds=self._idle_ttl_seconds,
175
+ one_time=True,
176
+ )
177
+
178
+ def open_once(self, artifact_id: str, nonce: str) -> ArtifactRead:
179
+ self.purge_expired()
180
+ artifact_dir, metadata = self._validated_artifact(artifact_id, nonce)
181
+ self._claim_artifact(artifact_dir, artifact_id)
182
+ self._record_access(artifact_dir, metadata, consume=True)
183
+ return _artifact_read(artifact_dir, metadata)
184
+
185
+ def inspect(self, artifact_id: str, nonce: str) -> ArtifactRead:
186
+ self.purge_expired()
187
+ artifact_dir, metadata = self._validated_artifact(artifact_id, nonce)
188
+ self._record_access(artifact_dir, metadata, consume=False)
189
+ return _artifact_read(artifact_dir, metadata)
190
+
191
+ def _validated_artifact(
192
+ self,
193
+ artifact_id: str,
194
+ nonce: str,
195
+ ) -> tuple[Path, dict[str, object]]:
196
+ artifact_dir = self._artifact_dir(artifact_id)
197
+ metadata = self._read_metadata(artifact_dir)
198
+ if (
199
+ metadata.get("consumed") is True
200
+ or (artifact_dir / CONSUMED_MARKER).exists()
201
+ ):
202
+ raise ArtifactConsumed(f"artifact already consumed: {artifact_id}")
203
+ if self._is_expired(metadata, time.time()):
204
+ shutil.rmtree(artifact_dir, ignore_errors=True)
205
+ raise ArtifactExpired(f"artifact expired: {artifact_id}")
206
+ expected_hash = str(metadata.get("nonce_sha256", ""))
207
+ actual_hash = hashlib.sha256(nonce.encode("utf-8")).hexdigest()
208
+ if not secrets.compare_digest(actual_hash, expected_hash):
209
+ raise ArtifactNotFound(f"artifact not found: {artifact_id}")
210
+ return artifact_dir, metadata
211
+
212
+ def _claim_artifact(self, artifact_dir: Path, artifact_id: str) -> None:
213
+ marker_path = artifact_dir / CONSUMED_MARKER
214
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
215
+ try:
216
+ with os.fdopen(os.open(marker_path, flags, 0o600), "wb") as handle:
217
+ handle.write(_iso_timestamp(time.time()).encode("utf-8"))
218
+ except FileExistsError as exc:
219
+ raise ArtifactConsumed(f"artifact already consumed: {artifact_id}") from exc
220
+ if os.name != "nt":
221
+ os.chmod(marker_path, 0o600)
222
+
223
+ def _record_access(
224
+ self,
225
+ artifact_dir: Path,
226
+ metadata: dict[str, object],
227
+ *,
228
+ consume: bool,
229
+ ) -> None:
230
+ accessed_at = time.time()
231
+ metadata["last_accessed_at"] = accessed_at
232
+ metadata["last_accessed_at_iso"] = _iso_timestamp(accessed_at)
233
+ metadata["access_count"] = _int_or_default(metadata.get("access_count"), 0) + 1
234
+ if consume:
235
+ metadata["consumed"] = True
236
+ metadata["nonce_sha256"] = None
237
+ _write_private_file(
238
+ artifact_dir / "metadata.json",
239
+ json.dumps(metadata, sort_keys=True).encode("utf-8"),
240
+ )
241
+
242
+ def purge_expired(self) -> int:
243
+ if not self._root.exists():
244
+ return 0
245
+ now = time.time()
246
+ removed = 0
247
+ for metadata_path in self._root.glob("*/artifacts/*/metadata.json"):
248
+ artifact_dir = metadata_path.parent
249
+ try:
250
+ metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
251
+ except (OSError, json.JSONDecodeError):
252
+ shutil.rmtree(artifact_dir, ignore_errors=True)
253
+ removed += 1
254
+ continue
255
+ if self._is_expired(metadata, now):
256
+ shutil.rmtree(artifact_dir, ignore_errors=True)
257
+ removed += 1
258
+ return removed
259
+
260
+ def _artifact_dir(self, artifact_id: str) -> Path:
261
+ if ARTIFACT_ID_PATTERN.fullmatch(artifact_id) is None:
262
+ raise ArtifactNotFound(f"artifact not found: {artifact_id}")
263
+ if not self._root.exists():
264
+ raise ArtifactNotFound(f"artifact not found: {artifact_id}")
265
+ matches = [
266
+ plugin_dir / "artifacts" / artifact_id
267
+ for plugin_dir in self._root.iterdir()
268
+ if (plugin_dir / "artifacts" / artifact_id / "metadata.json").is_file()
269
+ ]
270
+ if len(matches) != 1:
271
+ raise ArtifactNotFound(f"artifact not found: {artifact_id}")
272
+ return matches[0]
273
+
274
+ def _read_metadata(self, artifact_dir: Path) -> dict[str, object]:
275
+ try:
276
+ raw = (artifact_dir / "metadata.json").read_text(encoding="utf-8")
277
+ metadata = json.loads(raw)
278
+ except (OSError, json.JSONDecodeError) as exc:
279
+ raise ArtifactNotFound("artifact metadata is unavailable") from exc
280
+ if not isinstance(metadata, dict):
281
+ raise ArtifactNotFound("artifact metadata is invalid")
282
+ return metadata
283
+
284
+ def _is_expired(self, metadata: dict[str, object], now: float) -> bool:
285
+ created_at = _float_or_zero(metadata.get("created_at"))
286
+ last_accessed_at = metadata.get("last_accessed_at")
287
+ reference = (
288
+ _float_or_zero(last_accessed_at)
289
+ if isinstance(last_accessed_at, int | float)
290
+ else created_at
291
+ )
292
+ retention_seconds = _int_or_default(
293
+ metadata.get("retention_seconds"),
294
+ self._retention_seconds,
295
+ )
296
+ idle_ttl_seconds = _int_or_default(
297
+ metadata.get("idle_ttl_seconds"),
298
+ self._idle_ttl_seconds,
299
+ )
300
+ return (
301
+ created_at <= 0
302
+ or now - created_at > retention_seconds
303
+ or now - reference > idle_ttl_seconds
304
+ )
305
+
306
+
307
+ def _write_private_file(path: Path, content: bytes) -> None:
308
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
309
+ with os.fdopen(os.open(path, flags, 0o600), "wb") as handle:
310
+ handle.write(content)
311
+ if os.name != "nt":
312
+ os.chmod(path, 0o600)
313
+
314
+
315
+ def _iso_timestamp(value: float) -> str:
316
+ return datetime.fromtimestamp(value, tz=timezone.utc).isoformat()
317
+
318
+
319
+ def _float_or_zero(value: object) -> float:
320
+ if isinstance(value, int | float):
321
+ return float(value)
322
+ return 0.0
323
+
324
+
325
+ def _int_or_default(value: object, default: int) -> int:
326
+ if isinstance(value, int):
327
+ return value
328
+ return default
329
+
330
+
331
+ def _artifact_read(artifact_dir: Path, metadata: dict[str, object]) -> ArtifactRead:
332
+ return ArtifactRead(
333
+ path=artifact_dir / "payload",
334
+ filename=_string_or_none(metadata.get("filename")),
335
+ content_type=str(metadata.get("content_type", "application/octet-stream")),
336
+ size=_int_or_default(metadata.get("size"), 0),
337
+ sha256=str(metadata.get("sha256", "")),
338
+ )
339
+
340
+
341
+ def _string_or_none(value: object) -> str | None:
342
+ if isinstance(value, str):
343
+ return value
344
+ return None
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from collections.abc import Iterable
5
+ from typing import TextIO
6
+
7
+
8
+ def format_cli_error(
9
+ message: str,
10
+ *,
11
+ area: str | None = None,
12
+ details: Iterable[str] = (),
13
+ ) -> str:
14
+ area_text = f" {area}" if area else ""
15
+ message_lines = message.splitlines() or [""]
16
+ lines = [f"Arbiter{area_text} error: {message_lines[0]}"]
17
+ lines.extend(f" {line}" for line in message_lines[1:])
18
+ lines.extend(f" {detail}" for detail in details)
19
+ return "\n".join(lines)
20
+
21
+
22
+ def print_cli_error(
23
+ message: str,
24
+ *,
25
+ area: str | None = None,
26
+ details: Iterable[str] = (),
27
+ file: TextIO | None = None,
28
+ ) -> None:
29
+ if file is None:
30
+ file = sys.stderr
31
+ print(format_cli_error(message, area=area, details=details), file=file)