holdmyagent 2.0.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.
Files changed (50) hide show
  1. holdmyagent-2.0.0/PKG-INFO +41 -0
  2. holdmyagent-2.0.0/README.md +16 -0
  3. holdmyagent-2.0.0/arbiter/__init__.py +0 -0
  4. holdmyagent-2.0.0/arbiter/apns.py +4 -0
  5. holdmyagent-2.0.0/arbiter/app.py +159 -0
  6. holdmyagent-2.0.0/arbiter/auth.py +64 -0
  7. holdmyagent-2.0.0/arbiter/cli.py +195 -0
  8. holdmyagent-2.0.0/arbiter/config.py +128 -0
  9. holdmyagent-2.0.0/arbiter/db.py +167 -0
  10. holdmyagent-2.0.0/arbiter/main.py +15 -0
  11. holdmyagent-2.0.0/arbiter/models.py +47 -0
  12. holdmyagent-2.0.0/arbiter/notify/__init__.py +50 -0
  13. holdmyagent-2.0.0/arbiter/notify/apns.py +90 -0
  14. holdmyagent-2.0.0/arbiter/notify/ntfy.py +22 -0
  15. holdmyagent-2.0.0/arbiter/notify/webhook.py +35 -0
  16. holdmyagent-2.0.0/arbiter/pair.py +30 -0
  17. holdmyagent-2.0.0/arbiter/stream.py +21 -0
  18. holdmyagent-2.0.0/arbiter/web/__init__.py +178 -0
  19. holdmyagent-2.0.0/arbiter/web/static/dashboard.js +35 -0
  20. holdmyagent-2.0.0/arbiter/web/static/tokens.css +49 -0
  21. holdmyagent-2.0.0/arbiter/web/templates/_request_rows.html +13 -0
  22. holdmyagent-2.0.0/arbiter/web/templates/audit.html +18 -0
  23. holdmyagent-2.0.0/arbiter/web/templates/base.html +23 -0
  24. holdmyagent-2.0.0/arbiter/web/templates/devices.html +28 -0
  25. holdmyagent-2.0.0/arbiter/web/templates/login.html +9 -0
  26. holdmyagent-2.0.0/arbiter/web/templates/pair.html +15 -0
  27. holdmyagent-2.0.0/arbiter/web/templates/request_detail.html +26 -0
  28. holdmyagent-2.0.0/arbiter/web/templates/requests.html +6 -0
  29. holdmyagent-2.0.0/arbiter/web/templates/settings.html +32 -0
  30. holdmyagent-2.0.0/holdmyagent.egg-info/PKG-INFO +41 -0
  31. holdmyagent-2.0.0/holdmyagent.egg-info/SOURCES.txt +48 -0
  32. holdmyagent-2.0.0/holdmyagent.egg-info/dependency_links.txt +1 -0
  33. holdmyagent-2.0.0/holdmyagent.egg-info/entry_points.txt +2 -0
  34. holdmyagent-2.0.0/holdmyagent.egg-info/requires.txt +16 -0
  35. holdmyagent-2.0.0/holdmyagent.egg-info/top_level.txt +1 -0
  36. holdmyagent-2.0.0/pyproject.toml +49 -0
  37. holdmyagent-2.0.0/setup.cfg +4 -0
  38. holdmyagent-2.0.0/tests/test_api.py +171 -0
  39. holdmyagent-2.0.0/tests/test_apns.py +14 -0
  40. holdmyagent-2.0.0/tests/test_auth.py +31 -0
  41. holdmyagent-2.0.0/tests/test_cli.py +61 -0
  42. holdmyagent-2.0.0/tests/test_config.py +58 -0
  43. holdmyagent-2.0.0/tests/test_dashboard.py +165 -0
  44. holdmyagent-2.0.0/tests/test_db.py +32 -0
  45. holdmyagent-2.0.0/tests/test_migrations.py +44 -0
  46. holdmyagent-2.0.0/tests/test_notify.py +130 -0
  47. holdmyagent-2.0.0/tests/test_pair.py +64 -0
  48. holdmyagent-2.0.0/tests/test_retry.py +114 -0
  49. holdmyagent-2.0.0/tests/test_security.py +69 -0
  50. holdmyagent-2.0.0/tests/test_stream.py +35 -0
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.4
2
+ Name: holdmyagent
3
+ Version: 2.0.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,4 @@
1
+ """Compatibility shim: the APNs sender moved to arbiter.notify.apns."""
2
+ from .notify.apns import APNsSender, build_payload, send_with_retry
3
+
4
+ __all__ = ["APNsSender", "build_payload", "send_with_retry"]
@@ -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)