holdmyagent 0.2.0__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.
- holdmyagent-0.2.0/PKG-INFO +41 -0
- holdmyagent-0.2.0/README.md +16 -0
- holdmyagent-0.2.0/arbiter/__init__.py +0 -0
- holdmyagent-0.2.0/arbiter/apns.py +4 -0
- holdmyagent-0.2.0/arbiter/app.py +159 -0
- holdmyagent-0.2.0/arbiter/auth.py +64 -0
- holdmyagent-0.2.0/arbiter/cli.py +195 -0
- holdmyagent-0.2.0/arbiter/config.py +128 -0
- holdmyagent-0.2.0/arbiter/db.py +167 -0
- holdmyagent-0.2.0/arbiter/main.py +15 -0
- holdmyagent-0.2.0/arbiter/models.py +47 -0
- holdmyagent-0.2.0/arbiter/notify/__init__.py +50 -0
- holdmyagent-0.2.0/arbiter/notify/apns.py +90 -0
- holdmyagent-0.2.0/arbiter/notify/ntfy.py +22 -0
- holdmyagent-0.2.0/arbiter/notify/webhook.py +35 -0
- holdmyagent-0.2.0/arbiter/pair.py +30 -0
- holdmyagent-0.2.0/arbiter/stream.py +21 -0
- holdmyagent-0.2.0/arbiter/web/__init__.py +178 -0
- holdmyagent-0.2.0/arbiter/web/static/dashboard.js +35 -0
- holdmyagent-0.2.0/arbiter/web/static/tokens.css +49 -0
- holdmyagent-0.2.0/arbiter/web/templates/_request_rows.html +13 -0
- holdmyagent-0.2.0/arbiter/web/templates/audit.html +18 -0
- holdmyagent-0.2.0/arbiter/web/templates/base.html +23 -0
- holdmyagent-0.2.0/arbiter/web/templates/devices.html +28 -0
- holdmyagent-0.2.0/arbiter/web/templates/login.html +9 -0
- holdmyagent-0.2.0/arbiter/web/templates/pair.html +15 -0
- holdmyagent-0.2.0/arbiter/web/templates/request_detail.html +26 -0
- holdmyagent-0.2.0/arbiter/web/templates/requests.html +6 -0
- holdmyagent-0.2.0/arbiter/web/templates/settings.html +32 -0
- holdmyagent-0.2.0/holdmyagent.egg-info/PKG-INFO +41 -0
- holdmyagent-0.2.0/holdmyagent.egg-info/SOURCES.txt +48 -0
- holdmyagent-0.2.0/holdmyagent.egg-info/dependency_links.txt +1 -0
- holdmyagent-0.2.0/holdmyagent.egg-info/entry_points.txt +2 -0
- holdmyagent-0.2.0/holdmyagent.egg-info/requires.txt +16 -0
- holdmyagent-0.2.0/holdmyagent.egg-info/top_level.txt +1 -0
- holdmyagent-0.2.0/pyproject.toml +49 -0
- holdmyagent-0.2.0/setup.cfg +4 -0
- holdmyagent-0.2.0/tests/test_api.py +171 -0
- holdmyagent-0.2.0/tests/test_apns.py +14 -0
- holdmyagent-0.2.0/tests/test_auth.py +31 -0
- holdmyagent-0.2.0/tests/test_cli.py +61 -0
- holdmyagent-0.2.0/tests/test_config.py +58 -0
- holdmyagent-0.2.0/tests/test_dashboard.py +165 -0
- holdmyagent-0.2.0/tests/test_db.py +32 -0
- holdmyagent-0.2.0/tests/test_migrations.py +44 -0
- holdmyagent-0.2.0/tests/test_notify.py +130 -0
- holdmyagent-0.2.0/tests/test_pair.py +64 -0
- holdmyagent-0.2.0/tests/test_retry.py +114 -0
- holdmyagent-0.2.0/tests/test_security.py +69 -0
- holdmyagent-0.2.0/tests/test_stream.py +35 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: holdmyagent
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Self-hosted, fail-closed approval server for AI agents (Arbiter)
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://holdmyagent.com
|
|
7
|
+
Project-URL: Repository, https://github.com/holdmyagent/arbiter
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: fastapi>=0.115
|
|
11
|
+
Requires-Dist: uvicorn[standard]>=0.32
|
|
12
|
+
Requires-Dist: httpx[http2]>=0.27
|
|
13
|
+
Requires-Dist: pyjwt[crypto]>=2.9
|
|
14
|
+
Requires-Dist: pydantic>=2.9
|
|
15
|
+
Requires-Dist: segno>=1.6
|
|
16
|
+
Requires-Dist: jinja2>=3.1
|
|
17
|
+
Requires-Dist: itsdangerous>=2.2
|
|
18
|
+
Requires-Dist: python-multipart>=0.0.9
|
|
19
|
+
Requires-Dist: tomlkit>=0.12
|
|
20
|
+
Requires-Dist: click>=8.1
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=8.3; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
|
|
24
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
25
|
+
|
|
26
|
+
# Hold My Agent — Arbiter
|
|
27
|
+
|
|
28
|
+
Self-hosted, fail-closed approval server for AI agents. Before your agent
|
|
29
|
+
does something irreversible, it asks a human — Arbiter holds the request,
|
|
30
|
+
pushes an alert to your phone (APNs, ntfy, or a webhook), and waits for a
|
|
31
|
+
real decision. If the server is unreachable or the request times out, the
|
|
32
|
+
answer defaults to **no**.
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install holdmyagent
|
|
36
|
+
hma init
|
|
37
|
+
hma serve
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
- Homepage: https://holdmyagent.com
|
|
41
|
+
- Source / issues: https://github.com/holdmyagent/arbiter
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Hold My Agent — Arbiter
|
|
2
|
+
|
|
3
|
+
Self-hosted, fail-closed approval server for AI agents. Before your agent
|
|
4
|
+
does something irreversible, it asks a human — Arbiter holds the request,
|
|
5
|
+
pushes an alert to your phone (APNs, ntfy, or a webhook), and waits for a
|
|
6
|
+
real decision. If the server is unreachable or the request times out, the
|
|
7
|
+
answer defaults to **no**.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install holdmyagent
|
|
11
|
+
hma init
|
|
12
|
+
hma serve
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
- Homepage: https://holdmyagent.com
|
|
16
|
+
- Source / issues: https://github.com/holdmyagent/arbiter
|
|
File without changes
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import secrets
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI, Depends, HTTPException, WebSocket, WebSocketDisconnect
|
|
8
|
+
from fastapi.responses import RedirectResponse
|
|
9
|
+
from fastapi.staticfiles import StaticFiles
|
|
10
|
+
|
|
11
|
+
from .auth import require_agent, require_app, require_agent_or_app, SlidingWindowLimiter
|
|
12
|
+
from .models import RequestCreate, Decision, DeviceRegister
|
|
13
|
+
from .notify import Dispatcher
|
|
14
|
+
from .stream import Hub
|
|
15
|
+
from .web import build_router, session_valid
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger("arbiter.app")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def create_app(cfg, db, sender, hub: Hub | None = None, ws_heartbeat: float = 30.0, dispatcher=None):
|
|
21
|
+
hub = hub or Hub()
|
|
22
|
+
dispatcher = dispatcher or Dispatcher(cfg, db, sender=sender)
|
|
23
|
+
notify_tasks: set = set()
|
|
24
|
+
|
|
25
|
+
def _spawn(coro):
|
|
26
|
+
# Hold a strong reference until done — a bare create_task() result is
|
|
27
|
+
# GC-eligible mid-flight (asyncio only keeps weak refs to tasks).
|
|
28
|
+
t = asyncio.create_task(coro)
|
|
29
|
+
notify_tasks.add(t)
|
|
30
|
+
t.add_done_callback(notify_tasks.discard)
|
|
31
|
+
return t
|
|
32
|
+
|
|
33
|
+
@asynccontextmanager
|
|
34
|
+
async def lifespan(app):
|
|
35
|
+
async def sweep():
|
|
36
|
+
while True:
|
|
37
|
+
try:
|
|
38
|
+
for req in db.expire_due():
|
|
39
|
+
_spawn(dispatcher.request_decided(req))
|
|
40
|
+
await hub.publish("request.expired", "request", req)
|
|
41
|
+
except Exception as exc:
|
|
42
|
+
log.warning("sweep iteration failed: %s", exc)
|
|
43
|
+
await asyncio.sleep(1)
|
|
44
|
+
task = asyncio.create_task(sweep())
|
|
45
|
+
yield
|
|
46
|
+
task.cancel()
|
|
47
|
+
app = FastAPI(title="Arbiter", lifespan=lifespan)
|
|
48
|
+
app.state.hub = hub
|
|
49
|
+
app.state.notify_tasks = notify_tasks
|
|
50
|
+
limiter = SlidingWindowLimiter(10, 60.0)
|
|
51
|
+
app.state.login_limiter = SlidingWindowLimiter(5, 60.0)
|
|
52
|
+
agent = Depends(require_agent(cfg, limiter))
|
|
53
|
+
appdep = Depends(require_app(cfg, limiter))
|
|
54
|
+
either = Depends(require_agent_or_app(cfg, limiter))
|
|
55
|
+
|
|
56
|
+
app.mount("/static", StaticFiles(directory=str(Path(__file__).parent / "web" / "static")), name="static")
|
|
57
|
+
app.include_router(build_router(cfg, db, hub))
|
|
58
|
+
app.state.session_check = lambda v: session_valid(cfg, v)
|
|
59
|
+
|
|
60
|
+
@app.middleware("http")
|
|
61
|
+
async def security_headers(request, call_next):
|
|
62
|
+
resp = await call_next(request)
|
|
63
|
+
resp.headers["X-Content-Type-Options"] = "nosniff"
|
|
64
|
+
resp.headers["Referrer-Policy"] = "no-referrer"
|
|
65
|
+
resp.headers["X-Frame-Options"] = "DENY"
|
|
66
|
+
ct = resp.headers.get("content-type", "")
|
|
67
|
+
if ct.startswith("text/html") and not request.url.path.startswith(("/docs", "/redoc", "/openapi")):
|
|
68
|
+
resp.headers["Content-Security-Policy"] = "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
|
|
69
|
+
return resp
|
|
70
|
+
|
|
71
|
+
# ── Utility / pairing ────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
@app.get("/", include_in_schema=False)
|
|
74
|
+
def root():
|
|
75
|
+
return RedirectResponse("/dashboard", status_code=302)
|
|
76
|
+
|
|
77
|
+
@app.get("/dashboard", include_in_schema=False)
|
|
78
|
+
def dash_root():
|
|
79
|
+
return RedirectResponse("/dashboard/requests", status_code=302)
|
|
80
|
+
|
|
81
|
+
@app.get("/pair", include_in_schema=False)
|
|
82
|
+
def old_pair():
|
|
83
|
+
return RedirectResponse("/dashboard/pair", status_code=302)
|
|
84
|
+
|
|
85
|
+
@app.get("/health")
|
|
86
|
+
def health():
|
|
87
|
+
return {"ok": True}
|
|
88
|
+
|
|
89
|
+
# ── API v1 ───────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
@app.post("/v1/requests", dependencies=[agent])
|
|
92
|
+
async def create(body: RequestCreate):
|
|
93
|
+
req = db.create_request(body)
|
|
94
|
+
_spawn(dispatcher.request_created(req))
|
|
95
|
+
await hub.publish("request.created", "request", req)
|
|
96
|
+
return req
|
|
97
|
+
|
|
98
|
+
@app.get("/v1/requests", dependencies=[appdep])
|
|
99
|
+
def list_(status: str | None = None):
|
|
100
|
+
return db.list_requests(status)
|
|
101
|
+
|
|
102
|
+
@app.get("/v1/requests/{rid}", dependencies=[either])
|
|
103
|
+
def get_(rid: str):
|
|
104
|
+
r = db.get_request(rid)
|
|
105
|
+
if not r:
|
|
106
|
+
raise HTTPException(404, "not found")
|
|
107
|
+
return r
|
|
108
|
+
|
|
109
|
+
@app.post("/v1/requests/{rid}/decision", dependencies=[appdep])
|
|
110
|
+
async def decide(rid: str, body: Decision):
|
|
111
|
+
r = db.get_request(rid)
|
|
112
|
+
if not r:
|
|
113
|
+
raise HTTPException(404, "not found")
|
|
114
|
+
devices = db.list_devices()
|
|
115
|
+
decided_by = devices[0]["name"] if len(devices) == 1 else "app"
|
|
116
|
+
updated = db.set_decision(rid, body.decision, decided_by)
|
|
117
|
+
if not updated:
|
|
118
|
+
raise HTTPException(409, f"not pending (status={r['status']})")
|
|
119
|
+
_spawn(dispatcher.request_decided(updated))
|
|
120
|
+
await hub.publish("request.decided", "request", updated)
|
|
121
|
+
return updated
|
|
122
|
+
|
|
123
|
+
@app.post("/v1/devices", dependencies=[appdep])
|
|
124
|
+
async def register(body: DeviceRegister):
|
|
125
|
+
dev = db.register_device(body.apns_token, body.name, body.min_severity,
|
|
126
|
+
body.notifications_enabled, body.sound)
|
|
127
|
+
await hub.publish("device.updated", "device", dev)
|
|
128
|
+
return dev
|
|
129
|
+
|
|
130
|
+
@app.get("/v1/devices", dependencies=[appdep])
|
|
131
|
+
def devices():
|
|
132
|
+
return db.list_devices()
|
|
133
|
+
|
|
134
|
+
@app.websocket("/v1/stream")
|
|
135
|
+
async def stream(ws: WebSocket):
|
|
136
|
+
auth = ws.headers.get("authorization", "")
|
|
137
|
+
cookie = ws.cookies.get("hma_session", "")
|
|
138
|
+
token_ok = auth.startswith("Bearer ") and secrets.compare_digest(
|
|
139
|
+
auth.removeprefix("Bearer ").encode(), cfg.auth.app_token.encode())
|
|
140
|
+
if not (token_ok or app.state.session_check(cookie)):
|
|
141
|
+
await ws.close(code=4401)
|
|
142
|
+
return
|
|
143
|
+
await ws.accept()
|
|
144
|
+
q = hub.subscribe()
|
|
145
|
+
async def heartbeat():
|
|
146
|
+
while True:
|
|
147
|
+
await asyncio.sleep(ws_heartbeat)
|
|
148
|
+
await hub.publish("ping", "data", {})
|
|
149
|
+
hb = asyncio.create_task(heartbeat())
|
|
150
|
+
try:
|
|
151
|
+
while True:
|
|
152
|
+
await ws.send_json(await q.get())
|
|
153
|
+
except WebSocketDisconnect:
|
|
154
|
+
pass
|
|
155
|
+
finally:
|
|
156
|
+
hb.cancel()
|
|
157
|
+
hub.unsubscribe(q)
|
|
158
|
+
|
|
159
|
+
return app
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import secrets
|
|
3
|
+
import time
|
|
4
|
+
from collections import defaultdict, deque
|
|
5
|
+
from fastapi import Header, HTTPException, Request
|
|
6
|
+
|
|
7
|
+
log = logging.getLogger("arbiter.auth")
|
|
8
|
+
|
|
9
|
+
class SlidingWindowLimiter:
|
|
10
|
+
def __init__(self, limit: int, window: float, clock=time.monotonic):
|
|
11
|
+
self.limit, self.window, self.clock = limit, window, clock
|
|
12
|
+
self._hits: dict[str, deque] = defaultdict(deque)
|
|
13
|
+
|
|
14
|
+
def _prune(self, key: str):
|
|
15
|
+
q, now = self._hits[key], self.clock()
|
|
16
|
+
while q and now - q[0] > self.window:
|
|
17
|
+
q.popleft()
|
|
18
|
+
|
|
19
|
+
def record_failure(self, key: str):
|
|
20
|
+
self._prune(key)
|
|
21
|
+
self._hits[key].append(self.clock())
|
|
22
|
+
|
|
23
|
+
def blocked(self, key: str) -> bool:
|
|
24
|
+
q = self._hits.get(key)
|
|
25
|
+
if not q:
|
|
26
|
+
return False
|
|
27
|
+
now = self.clock()
|
|
28
|
+
while q and now - q[0] > self.window:
|
|
29
|
+
q.popleft()
|
|
30
|
+
if not q:
|
|
31
|
+
del self._hits[key]
|
|
32
|
+
return len(q) >= self.limit
|
|
33
|
+
|
|
34
|
+
def _client_ip(request: Request) -> str:
|
|
35
|
+
return request.client.host if request.client else "unknown"
|
|
36
|
+
|
|
37
|
+
def _check(request: Request, authorization: str | None, expected: tuple[str, ...], limiter: SlidingWindowLimiter):
|
|
38
|
+
ip = _client_ip(request)
|
|
39
|
+
if limiter.blocked(ip):
|
|
40
|
+
raise HTTPException(429, "too many failed auth attempts")
|
|
41
|
+
if not authorization or not authorization.startswith("Bearer "):
|
|
42
|
+
limiter.record_failure(ip)
|
|
43
|
+
log.warning("auth_failure ip=%s reason=missing_bearer", ip)
|
|
44
|
+
raise HTTPException(401, "missing bearer token")
|
|
45
|
+
supplied = authorization.removeprefix("Bearer ")
|
|
46
|
+
if not any(secrets.compare_digest(supplied.encode(), e.encode()) for e in expected):
|
|
47
|
+
limiter.record_failure(ip)
|
|
48
|
+
log.warning("auth_failure ip=%s reason=invalid_token", ip) # never log the supplied value
|
|
49
|
+
raise HTTPException(403, "invalid token")
|
|
50
|
+
|
|
51
|
+
def require_agent(cfg, limiter):
|
|
52
|
+
def dep(request: Request, authorization: str | None = Header(default=None)):
|
|
53
|
+
_check(request, authorization, (cfg.auth.agent_token,), limiter)
|
|
54
|
+
return dep
|
|
55
|
+
|
|
56
|
+
def require_app(cfg, limiter):
|
|
57
|
+
def dep(request: Request, authorization: str | None = Header(default=None)):
|
|
58
|
+
_check(request, authorization, (cfg.auth.app_token,), limiter)
|
|
59
|
+
return dep
|
|
60
|
+
|
|
61
|
+
def require_agent_or_app(cfg, limiter):
|
|
62
|
+
def dep(request: Request, authorization: str | None = Header(default=None)):
|
|
63
|
+
_check(request, authorization, (cfg.auth.agent_token, cfg.auth.app_token), limiter)
|
|
64
|
+
return dep
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import secrets as pysecrets
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import click
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from .config import Config
|
|
12
|
+
|
|
13
|
+
class _JsonFormatter(logging.Formatter):
|
|
14
|
+
def format(self, rec):
|
|
15
|
+
return json.dumps({"ts": self.formatTime(rec), "level": rec.levelname,
|
|
16
|
+
"logger": rec.name, "msg": rec.getMessage()})
|
|
17
|
+
|
|
18
|
+
CONFIG_TEMPLATE = """# Hold My Agent — Arbiter server configuration
|
|
19
|
+
[server]
|
|
20
|
+
host = "127.0.0.1" # use "0.0.0.0" (or `hma serve --lan`) so phones can reach it
|
|
21
|
+
port = 8000
|
|
22
|
+
db_path = "~/.local/share/holdmyagent/arbiter.sqlite3"
|
|
23
|
+
|
|
24
|
+
[auth]
|
|
25
|
+
agent_token = "{agent}"
|
|
26
|
+
app_token = "{app}"
|
|
27
|
+
admin_password = "{admin}"
|
|
28
|
+
session_secret = "{session}"
|
|
29
|
+
|
|
30
|
+
[notify.apns] # optional — bring your own Apple Developer key
|
|
31
|
+
key_path = ""
|
|
32
|
+
key_id = ""
|
|
33
|
+
team_id = ""
|
|
34
|
+
bundle_id = "com.holdmyagent.HoldMyAgent"
|
|
35
|
+
sandbox = false
|
|
36
|
+
|
|
37
|
+
[notify.ntfy] # optional — phone alerts with no Apple account
|
|
38
|
+
url = "https://ntfy.sh"
|
|
39
|
+
topic = ""
|
|
40
|
+
token = ""
|
|
41
|
+
|
|
42
|
+
[notify.webhook] # optional — generic integration
|
|
43
|
+
url = ""
|
|
44
|
+
secret = ""
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
@click.group()
|
|
48
|
+
def main():
|
|
49
|
+
"""Hold My Agent — self-hosted, fail-closed approvals for AI agents."""
|
|
50
|
+
|
|
51
|
+
@main.command()
|
|
52
|
+
@click.option("--force", is_flag=True, help="Overwrite an existing config file.")
|
|
53
|
+
def init(force):
|
|
54
|
+
"""Write a fresh config with random credentials."""
|
|
55
|
+
path = Path(Config.default_path())
|
|
56
|
+
if path.exists():
|
|
57
|
+
if not force:
|
|
58
|
+
raise click.ClickException(f"{path} exists (use --force to overwrite)")
|
|
59
|
+
path.unlink()
|
|
60
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
creds = dict(agent=pysecrets.token_hex(32), app=pysecrets.token_hex(32),
|
|
62
|
+
admin=pysecrets.token_urlsafe(16), session=pysecrets.token_hex(32))
|
|
63
|
+
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
64
|
+
with os.fdopen(fd, "w") as f:
|
|
65
|
+
f.write(CONFIG_TEMPLATE.format(**creds))
|
|
66
|
+
click.echo(f"Wrote {path}")
|
|
67
|
+
click.echo(f" agent token: {creds['agent']}")
|
|
68
|
+
click.echo(f" app token: {creds['app']}")
|
|
69
|
+
click.echo(f" admin password: {creds['admin']} (dashboard login)")
|
|
70
|
+
click.echo("Shown once — they live in the config file from now on.")
|
|
71
|
+
|
|
72
|
+
@main.command()
|
|
73
|
+
@click.option("--config", "config_path", default=None, help="Path to config.toml")
|
|
74
|
+
@click.option("--lan", is_flag=True, help="Bind 0.0.0.0 and print the LAN pairing URL.")
|
|
75
|
+
@click.option("--log-json", is_flag=True, help="Emit logs as JSON lines.")
|
|
76
|
+
def serve(config_path, lan, log_json):
|
|
77
|
+
"""Run the Arbiter server."""
|
|
78
|
+
import uvicorn
|
|
79
|
+
from .pair import local_ip
|
|
80
|
+
if log_json:
|
|
81
|
+
h = logging.StreamHandler()
|
|
82
|
+
h.setFormatter(_JsonFormatter())
|
|
83
|
+
logging.basicConfig(level=logging.INFO, handlers=[h])
|
|
84
|
+
else:
|
|
85
|
+
logging.basicConfig(level=logging.INFO,
|
|
86
|
+
format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
|
87
|
+
cfg = Config.load(config_path)
|
|
88
|
+
problems = cfg.validate_for_serve()
|
|
89
|
+
if problems:
|
|
90
|
+
raise click.ClickException("refusing to start:\n - " + "\n - ".join(problems))
|
|
91
|
+
host = "0.0.0.0" if lan else cfg.server.host
|
|
92
|
+
if lan or host == "0.0.0.0":
|
|
93
|
+
click.echo(f"Pair page: http://{local_ip()}:{cfg.server.port}/dashboard/pair")
|
|
94
|
+
from .db import Database
|
|
95
|
+
from .notify import Dispatcher, APNsSender
|
|
96
|
+
from .app import create_app
|
|
97
|
+
db = Database(cfg.db_path_expanded())
|
|
98
|
+
sender = APNsSender(cfg)
|
|
99
|
+
app = create_app(cfg, db, sender, dispatcher=Dispatcher(cfg, db, sender=sender))
|
|
100
|
+
# log_config=None: leave uvicorn's loggers unconfigured so they propagate to
|
|
101
|
+
# the root handler set up above (JSON or plain) instead of uvicorn's own
|
|
102
|
+
# dictConfig (which sets propagate=False with plain formatters).
|
|
103
|
+
uvicorn.run(app, host=host, port=cfg.server.port, log_config=None)
|
|
104
|
+
|
|
105
|
+
@main.command()
|
|
106
|
+
@click.option("--config", "config_path", default=None)
|
|
107
|
+
@click.option("--host", "host_url", default=None, help="Server base URL for the QR (default http://<LAN-IP>:<port>)")
|
|
108
|
+
def pair(config_path, host_url):
|
|
109
|
+
"""Print the pairing QR code in the terminal."""
|
|
110
|
+
import segno
|
|
111
|
+
from .pair import build_pairing_payload, local_ip
|
|
112
|
+
cfg = Config.load(config_path)
|
|
113
|
+
if not cfg.auth.app_token:
|
|
114
|
+
raise click.ClickException("no app_token in config — run `hma init` first")
|
|
115
|
+
base = host_url or f"http://{local_ip()}:{cfg.server.port}"
|
|
116
|
+
payload = build_pairing_payload(base, cfg.auth.app_token)
|
|
117
|
+
click.echo(segno.make(payload).terminal(compact=True))
|
|
118
|
+
click.echo(f"URL: {base}")
|
|
119
|
+
click.echo(f"Payload: {payload}")
|
|
120
|
+
|
|
121
|
+
def _gather_status(client: httpx.Client, app_token: str) -> dict:
|
|
122
|
+
hdr = {"Authorization": f"Bearer {app_token}"}
|
|
123
|
+
r = client.get("/health")
|
|
124
|
+
r.raise_for_status()
|
|
125
|
+
d = client.get("/v1/devices", headers=hdr)
|
|
126
|
+
d.raise_for_status()
|
|
127
|
+
p = client.get("/v1/requests", headers=hdr, params={"status": "pending"})
|
|
128
|
+
p.raise_for_status()
|
|
129
|
+
return {"ok": r.json().get("ok", False), "devices": d.json(), "pending": p.json()}
|
|
130
|
+
|
|
131
|
+
@main.command()
|
|
132
|
+
@click.option("--config", "config_path", default=None)
|
|
133
|
+
def status(config_path):
|
|
134
|
+
"""Show server health, devices, and pending requests."""
|
|
135
|
+
cfg = Config.load(config_path)
|
|
136
|
+
base = f"http://127.0.0.1:{cfg.server.port}"
|
|
137
|
+
try:
|
|
138
|
+
with httpx.Client(base_url=base, timeout=5) as c:
|
|
139
|
+
st = _gather_status(c, cfg.auth.app_token)
|
|
140
|
+
except httpx.HTTPStatusError as exc:
|
|
141
|
+
raise click.ClickException(f"server error at {base}: {exc}")
|
|
142
|
+
except httpx.HTTPError as exc:
|
|
143
|
+
raise click.ClickException(f"server unreachable at {base}: {exc}")
|
|
144
|
+
ok, devices, pending = st["ok"], st["devices"], st["pending"]
|
|
145
|
+
click.echo(f"health: {'ok' if ok else 'NOT OK'}")
|
|
146
|
+
click.echo(f"notifiers: apns={'on' if cfg.apns.configured else 'off'} "
|
|
147
|
+
f"ntfy={'on' if cfg.ntfy.enabled else 'off'} webhook={'on' if cfg.webhook.enabled else 'off'}")
|
|
148
|
+
click.echo(f"devices: {len(devices)}")
|
|
149
|
+
for d in devices:
|
|
150
|
+
click.echo(f" - {d['name']} (min severity {d['min_severity']})")
|
|
151
|
+
click.echo(f"pending requests: {len(pending)}")
|
|
152
|
+
|
|
153
|
+
def _ask(client: httpx.Client, agent_token: str, *, title: str, severity: str,
|
|
154
|
+
target: str | None, ttl: int, description: str) -> tuple[int, dict]:
|
|
155
|
+
hdr = {"Authorization": f"Bearer {agent_token}"}
|
|
156
|
+
try:
|
|
157
|
+
r = client.post("/v1/requests", headers=hdr, json={
|
|
158
|
+
"title": title, "description": description, "severity": severity,
|
|
159
|
+
"target": target, "ttl_seconds": ttl})
|
|
160
|
+
r.raise_for_status()
|
|
161
|
+
req = r.json()
|
|
162
|
+
deadline = time.time() + ttl + 5
|
|
163
|
+
while time.time() < deadline:
|
|
164
|
+
g = client.get(f"/v1/requests/{req['id']}", headers=hdr)
|
|
165
|
+
g.raise_for_status()
|
|
166
|
+
cur = g.json()
|
|
167
|
+
if cur["status"] != "pending":
|
|
168
|
+
return (0 if cur["status"] == "approved" else 1), cur
|
|
169
|
+
time.sleep(1)
|
|
170
|
+
return 1, {**req, "status": "expired"}
|
|
171
|
+
except Exception as exc:
|
|
172
|
+
return 2, {"error": str(exc)}
|
|
173
|
+
|
|
174
|
+
@main.command()
|
|
175
|
+
@click.argument("title")
|
|
176
|
+
@click.option("--severity", type=click.Choice(["low", "medium", "high", "critical"]), default="medium")
|
|
177
|
+
@click.option("--target", default=None)
|
|
178
|
+
@click.option("--ttl", type=int, default=300)
|
|
179
|
+
@click.option("--description", default="")
|
|
180
|
+
@click.option("--config", "config_path", default=None)
|
|
181
|
+
def ask(title, severity, target, ttl, description, config_path):
|
|
182
|
+
"""Create an approval request and block until it is decided.
|
|
183
|
+
|
|
184
|
+
Exit codes: 0 approved · 1 denied/expired · 2 error (fail-closed: treat nonzero as no).
|
|
185
|
+
"""
|
|
186
|
+
cfg = Config.load(config_path)
|
|
187
|
+
base = f"http://127.0.0.1:{cfg.server.port}"
|
|
188
|
+
with httpx.Client(base_url=base, timeout=10) as client:
|
|
189
|
+
code, decision = _ask(client, cfg.auth.agent_token, title=title,
|
|
190
|
+
severity=severity, target=target, ttl=ttl, description=description)
|
|
191
|
+
click.echo(json.dumps(decision, indent=2))
|
|
192
|
+
sys.exit(code)
|
|
193
|
+
|
|
194
|
+
if __name__ == "__main__":
|
|
195
|
+
main()
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import tomllib
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
def _b(v: str) -> bool:
|
|
7
|
+
return v.strip().lower() in ("1", "true")
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ServerCfg:
|
|
11
|
+
host: str = "127.0.0.1"
|
|
12
|
+
port: int = 8000
|
|
13
|
+
db_path: str = "~/.local/share/holdmyagent/arbiter.sqlite3"
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class AuthCfg:
|
|
17
|
+
agent_token: str = ""
|
|
18
|
+
app_token: str = ""
|
|
19
|
+
admin_password: str = ""
|
|
20
|
+
session_secret: str = ""
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ApnsCfg:
|
|
24
|
+
key_path: str = ""
|
|
25
|
+
key_id: str = ""
|
|
26
|
+
team_id: str = ""
|
|
27
|
+
bundle_id: str = "com.holdmyagent.HoldMyAgent"
|
|
28
|
+
sandbox: bool = False
|
|
29
|
+
@property
|
|
30
|
+
def configured(self) -> bool:
|
|
31
|
+
return bool(self.key_path and self.key_id and self.team_id)
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class NtfyCfg:
|
|
35
|
+
url: str = "https://ntfy.sh"
|
|
36
|
+
topic: str = ""
|
|
37
|
+
token: str = ""
|
|
38
|
+
@property
|
|
39
|
+
def enabled(self) -> bool:
|
|
40
|
+
return bool(self.topic)
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class WebhookCfg:
|
|
44
|
+
url: str = ""
|
|
45
|
+
secret: str = ""
|
|
46
|
+
@property
|
|
47
|
+
def enabled(self) -> bool:
|
|
48
|
+
return bool(self.url)
|
|
49
|
+
|
|
50
|
+
_DEFAULT_TOKENS = {"dev-agent-token", "dev-app-token"}
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Config:
|
|
54
|
+
server: ServerCfg = field(default_factory=ServerCfg)
|
|
55
|
+
auth: AuthCfg = field(default_factory=AuthCfg)
|
|
56
|
+
apns: ApnsCfg = field(default_factory=ApnsCfg)
|
|
57
|
+
ntfy: NtfyCfg = field(default_factory=NtfyCfg)
|
|
58
|
+
webhook: WebhookCfg = field(default_factory=WebhookCfg)
|
|
59
|
+
loaded_path: str = ""
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def default_path() -> str:
|
|
63
|
+
return os.environ.get("HMA_CONFIG") or str(Path("~/.config/holdmyagent/config.toml").expanduser())
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def load(path: str | None = None) -> "Config":
|
|
67
|
+
cfg = Config()
|
|
68
|
+
p = Path(path or Config.default_path()).expanduser()
|
|
69
|
+
cfg.loaded_path = str(p)
|
|
70
|
+
if p.is_file():
|
|
71
|
+
with open(p, "rb") as f:
|
|
72
|
+
doc = tomllib.load(f)
|
|
73
|
+
s = doc.get("server", {})
|
|
74
|
+
a = doc.get("auth", {})
|
|
75
|
+
n = doc.get("notify", {})
|
|
76
|
+
for k in ("host", "port", "db_path"):
|
|
77
|
+
if k in s:
|
|
78
|
+
setattr(cfg.server, k, s[k])
|
|
79
|
+
for k in ("agent_token", "app_token", "admin_password", "session_secret"):
|
|
80
|
+
if k in a:
|
|
81
|
+
setattr(cfg.auth, k, a[k])
|
|
82
|
+
for k in ("key_path", "key_id", "team_id", "bundle_id", "sandbox"):
|
|
83
|
+
if k in n.get("apns", {}):
|
|
84
|
+
setattr(cfg.apns, k, n["apns"][k])
|
|
85
|
+
for k in ("url", "topic", "token"):
|
|
86
|
+
if k in n.get("ntfy", {}):
|
|
87
|
+
setattr(cfg.ntfy, k, n["ntfy"][k])
|
|
88
|
+
for k in ("url", "secret"):
|
|
89
|
+
if k in n.get("webhook", {}):
|
|
90
|
+
setattr(cfg.webhook, k, n["webhook"][k])
|
|
91
|
+
env = os.environ
|
|
92
|
+
m = [("HMA_HOST", cfg.server, "host", str), ("HMA_PORT", cfg.server, "port", int),
|
|
93
|
+
("HMA_DB_PATH", cfg.server, "db_path", str),
|
|
94
|
+
("HMA_AGENT_TOKEN", cfg.auth, "agent_token", str), ("HMA_APP_TOKEN", cfg.auth, "app_token", str),
|
|
95
|
+
("HMA_ADMIN_PASSWORD", cfg.auth, "admin_password", str),
|
|
96
|
+
("HMA_SESSION_SECRET", cfg.auth, "session_secret", str),
|
|
97
|
+
("HMA_APNS_KEY_PATH", cfg.apns, "key_path", str), ("HMA_APNS_KEY_ID", cfg.apns, "key_id", str),
|
|
98
|
+
("HMA_APNS_TEAM_ID", cfg.apns, "team_id", str), ("HMA_APNS_BUNDLE_ID", cfg.apns, "bundle_id", str),
|
|
99
|
+
("HMA_APNS_SANDBOX", cfg.apns, "sandbox", _b),
|
|
100
|
+
("HMA_NTFY_URL", cfg.ntfy, "url", str), ("HMA_NTFY_TOPIC", cfg.ntfy, "topic", str),
|
|
101
|
+
("HMA_NTFY_TOKEN", cfg.ntfy, "token", str),
|
|
102
|
+
("HMA_WEBHOOK_URL", cfg.webhook, "url", str), ("HMA_WEBHOOK_SECRET", cfg.webhook, "secret", str)]
|
|
103
|
+
for name, obj, attr, cast in m:
|
|
104
|
+
if name in env:
|
|
105
|
+
setattr(obj, attr, cast(env[name]))
|
|
106
|
+
return cfg
|
|
107
|
+
|
|
108
|
+
def validate_for_serve(self) -> list[str]:
|
|
109
|
+
p: list[str] = []
|
|
110
|
+
a = self.auth
|
|
111
|
+
if not a.agent_token:
|
|
112
|
+
p.append("auth.agent_token is empty — run `hma init`")
|
|
113
|
+
if not a.app_token:
|
|
114
|
+
p.append("auth.app_token is empty — run `hma init`")
|
|
115
|
+
if not a.admin_password:
|
|
116
|
+
p.append("auth.admin_password is empty — run `hma init`")
|
|
117
|
+
if not a.session_secret:
|
|
118
|
+
p.append("auth.session_secret is empty — run `hma init`")
|
|
119
|
+
if a.agent_token in _DEFAULT_TOKENS or a.app_token in _DEFAULT_TOKENS:
|
|
120
|
+
p.append("refusing to run with default dev tokens")
|
|
121
|
+
if a.agent_token and a.agent_token == a.app_token:
|
|
122
|
+
p.append("agent_token and app_token must differ")
|
|
123
|
+
return p
|
|
124
|
+
|
|
125
|
+
def db_path_expanded(self) -> str:
|
|
126
|
+
path = Path(self.server.db_path).expanduser()
|
|
127
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
return str(path)
|