pivotra 1.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.
@@ -0,0 +1,42 @@
1
+ # Python
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ *.pyd
6
+ .Python
7
+ *.egg-info/
8
+ dist/
9
+ build/
10
+ venv/
11
+ .venv/
12
+ *.bak
13
+ *.bak.*
14
+
15
+ # Secrets — NEVER commit
16
+ .env
17
+ .env.*
18
+ *.pem
19
+ *.key
20
+ *.p12
21
+ *.pfx
22
+ secrets/
23
+ service_account*.json
24
+ google-services.json
25
+
26
+ # Frontend
27
+ node_modules/
28
+ frontend/dist/
29
+
30
+ # Logs
31
+ *.log
32
+
33
+ # OS
34
+ .DS_Store
35
+ Thumbs.db
36
+
37
+ # IDE
38
+ .vscode/
39
+ .idea/
40
+
41
+ # K8s generated
42
+ k8s/secrets/
pivotra-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: pivotra
3
+ Version: 1.0.0
4
+ Summary: Pivotra AI Platform CLI — Fleet management + BRO AI
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: httpx>=0.27.0
7
+ Requires-Dist: prompt-toolkit>=3.0.43
8
+ Requires-Dist: rich>=13.7.0
9
+ Requires-Dist: typer>=0.12.0
10
+ Requires-Dist: websockets>=12.0
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env bash
2
+ # Pivotra CLI — one-line installer
3
+ # Usage: curl -sSL https://monitor.kymnscorp.com/install.sh | bash
4
+
5
+ set -e
6
+
7
+ REPO="https://github.com/pivotra-ai/pivotra-cli" # update when published
8
+ INSTALL_DIR="$HOME/.local/bin"
9
+ CLI_DIR="$HOME/.pivotra/cli"
10
+
11
+ echo ""
12
+ echo " Pivotra CLI — installer"
13
+ echo " ───────────────────────"
14
+ echo ""
15
+
16
+ # Check Python 3.10+
17
+ python_cmd=""
18
+ for cmd in python3.12 python3.11 python3.10 python3; do
19
+ if command -v "$cmd" &>/dev/null; then
20
+ ver=$("$cmd" -c "import sys; print(sys.version_info >= (3,10))" 2>/dev/null)
21
+ if [ "$ver" = "True" ]; then
22
+ python_cmd="$cmd"
23
+ break
24
+ fi
25
+ fi
26
+ done
27
+
28
+ if [ -z "$python_cmd" ]; then
29
+ echo " ERROR: Python 3.10+ required. Install from https://python.org"
30
+ exit 1
31
+ fi
32
+
33
+ echo " Using $python_cmd ($($python_cmd --version))"
34
+
35
+ # Create venv
36
+ mkdir -p "$CLI_DIR"
37
+ $python_cmd -m venv "$CLI_DIR/venv"
38
+ source "$CLI_DIR/venv/bin/activate"
39
+
40
+ # Install from local source (dev) or PyPI (prod)
41
+ if [ -f "$(dirname "$0")/pyproject.toml" ]; then
42
+ echo " Installing from local source..."
43
+ pip install -q -e "$(dirname "$0")"
44
+ else
45
+ echo " Installing from PyPI..."
46
+ pip install -q pivotra
47
+ fi
48
+
49
+ # Create wrapper in ~/.local/bin
50
+ mkdir -p "$INSTALL_DIR"
51
+ cat > "$INSTALL_DIR/pivotra" << EOF
52
+ #!/usr/bin/env bash
53
+ source "$CLI_DIR/venv/bin/activate"
54
+ exec pivotra "\$@"
55
+ EOF
56
+ chmod +x "$INSTALL_DIR/pivotra"
57
+
58
+ echo ""
59
+ echo " Installed → $INSTALL_DIR/pivotra"
60
+ echo ""
61
+ echo " Add to PATH if needed:"
62
+ echo " export PATH=\"\$HOME/.local/bin:\$PATH\""
63
+ echo ""
64
+ echo " Get started:"
65
+ echo " pivotra login"
66
+ echo " pivotra status"
67
+ echo " pivotra bro"
68
+ echo ""
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
@@ -0,0 +1,2 @@
1
+ from .app import main
2
+ main()
@@ -0,0 +1,561 @@
1
+ """
2
+ REST client for Pivotra backend API.
3
+ All calls authenticated with JWT stored by auth.py.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import sys
8
+ from typing import Any, Optional
9
+
10
+ import httpx
11
+ from rich.console import Console
12
+
13
+ from .auth import get_base_url, require_token
14
+
15
+ console = Console()
16
+
17
+
18
+ _UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 PivotraCLI/1.0"
19
+
20
+ def _headers() -> dict:
21
+ return {
22
+ "Authorization": f"Bearer {require_token()}",
23
+ "User-Agent": _UA,
24
+ }
25
+
26
+
27
+ def _base() -> str:
28
+ return get_base_url()
29
+
30
+
31
+ class APIError(Exception):
32
+ def __init__(self, status_code: int, message: str):
33
+ self.status_code = status_code
34
+ self.message = message
35
+ super().__init__(f"API error {status_code}: {message}")
36
+
37
+
38
+ def _get(path: str, params: Optional[dict] = None, timeout: int = 10) -> Any:
39
+ url = f"{_base()}{path}"
40
+ try:
41
+ with httpx.Client(timeout=timeout, follow_redirects=True) as c:
42
+ r = c.get(url, headers=_headers(), params=params or {})
43
+ if r.status_code == 401:
44
+ console.print("[red]Session expired.[/red] Run [bold]pivotra login[/bold].")
45
+ sys.exit(1)
46
+ r.raise_for_status()
47
+ return r.json()
48
+ except httpx.HTTPStatusError as e:
49
+ raise APIError(e.response.status_code, e.response.text[:200])
50
+ except httpx.RequestError as e:
51
+ raise APIError(0, str(e))
52
+
53
+
54
+ def _post(path: str, payload: dict, timeout: int = 15) -> Any:
55
+ url = f"{_base()}{path}"
56
+ try:
57
+ with httpx.Client(timeout=timeout) as c:
58
+ r = c.post(url, headers=_headers(), json=payload)
59
+ if r.status_code == 401:
60
+ console.print("[red]Session expired.[/red] Run [bold]pivotra login[/bold].")
61
+ sys.exit(1)
62
+ r.raise_for_status()
63
+ return r.json()
64
+ except httpx.HTTPStatusError as e:
65
+ console.print(f"[red]API error {e.response.status_code}:[/red] {e.response.text[:200]}")
66
+ sys.exit(1)
67
+ except httpx.RequestError as e:
68
+ console.print(f"[red]Connection error:[/red] {e}")
69
+ sys.exit(1)
70
+
71
+
72
+ def _delete(path: str, timeout: int = 15) -> Any:
73
+ url = f"{_base()}{path}"
74
+ try:
75
+ with httpx.Client(timeout=timeout) as c:
76
+ r = c.delete(url, headers=_headers())
77
+ if r.status_code == 401:
78
+ console.print("[red]Session expired.[/red] Run [bold]pivotra login[/bold].")
79
+ sys.exit(1)
80
+ r.raise_for_status()
81
+ return r.json()
82
+ except httpx.HTTPStatusError as e:
83
+ console.print(f"[red]API error {e.response.status_code}:[/red] {e.response.text[:200]}")
84
+ sys.exit(1)
85
+ except httpx.RequestError as e:
86
+ console.print(f"[red]Connection error:[/red] {e}")
87
+ sys.exit(1)
88
+
89
+
90
+ # ── Agents ────────────────────────────────────────────────────────────────────
91
+
92
+ def list_agents() -> list[dict]:
93
+ return _get("/api/v1/agents/")
94
+
95
+
96
+ def get_agent(agent_id: str) -> dict:
97
+ return _get(f"/api/v1/agents/{agent_id}/")
98
+
99
+
100
+ def get_agent_logs(agent_id: str, source: str = "", limit: int = 50) -> list[dict]:
101
+ params = {"source": source, "limit": limit} if source else {"limit": limit}
102
+ return _get(f"/api/v1/agents/{agent_id}/logs", params=params)
103
+
104
+
105
+ def get_agent_anomalies(agent_id: str, limit: int = 20) -> list[dict]:
106
+ return _get(f"/api/v1/agents/{agent_id}/anomalies", params={"limit": limit})
107
+
108
+
109
+ def get_agent_processes(agent_id: str) -> list[dict]:
110
+ return _get(f"/api/v1/agents/{agent_id}/processes")
111
+
112
+
113
+ def get_agent_connections(agent_id: str) -> list[dict]:
114
+ return _get(f"/api/v1/agents/{agent_id}/connections")
115
+
116
+
117
+ def get_agent_metrics(agent_id: str) -> dict:
118
+ return _get(f"/api/v1/agents/{agent_id}/metrics")
119
+
120
+
121
+ def get_agent_vulns(agent_id: str) -> list[dict]:
122
+ return _get(f"/api/v1/agents/{agent_id}/vulns")
123
+
124
+
125
+ def send_command(agent_id: str, command_type: str, payload: dict = {}) -> dict:
126
+ return _post(f"/api/v1/agents/{agent_id}/command",
127
+ {"type": command_type, "payload": payload})
128
+
129
+
130
+ def isolate_agent(agent_id: str) -> dict:
131
+ return _post(f"/api/v1/agents/{agent_id}/isolate", {})
132
+
133
+
134
+ def unisolate_agent(agent_id: str) -> dict:
135
+ return _post(f"/api/v1/agents/{agent_id}/unisolate", {})
136
+
137
+
138
+ def block_ip(ip: str, agent_id: Optional[str] = None) -> dict:
139
+ payload = {"ip": ip}
140
+ if agent_id:
141
+ payload["agent_id"] = agent_id
142
+ return _post("/api/v1/agents/broadcast",
143
+ {"type": "block_ip", "payload": payload})
144
+
145
+
146
+ def run_command(agent_id: str, command: str) -> dict:
147
+ return _post(f"/api/v1/agents/{agent_id}/command",
148
+ {"type": "run_script", "payload": {"command": command}})
149
+
150
+
151
+ def trigger_scan(agent_id: str) -> dict:
152
+ return send_command(agent_id, "scan", {})
153
+
154
+
155
+ # ── Orchestration ──────────────────────────────────────────────────────────────
156
+
157
+ def generate_key(label: str = "") -> dict:
158
+ payload = {"label": label} if label else {}
159
+ return _post("/api/v1/agents/generate-key", payload)
160
+
161
+
162
+ def spawn_agent(agent_id: str, config: dict = {}) -> dict:
163
+ return _post(f"/api/v1/agents/{agent_id}/spawn", config)
164
+
165
+
166
+ def get_sub_agents(agent_id: str) -> list[dict]:
167
+ return _get(f"/api/v1/agents/{agent_id}/sub-agents")
168
+
169
+
170
+ def mesh_cmd(agent_id: str, command: str, targets: list[str] = []) -> dict:
171
+ return _post(f"/api/v1/agents/{agent_id}/mesh-cmd",
172
+ {"command": command, "targets": targets})
173
+
174
+
175
+ def broadcast_command(command: str) -> dict:
176
+ return _post("/api/v1/agents/broadcast",
177
+ {"type": "run_script", "payload": {"command": command}})
178
+
179
+
180
+ def deregister_agent(agent_id: str) -> dict:
181
+ url = f"{_base()}/api/v1/agents/{agent_id}"
182
+ try:
183
+ with httpx.Client(timeout=10) as c:
184
+ r = c.delete(url, headers=_headers())
185
+ if r.status_code == 401:
186
+ console.print("[red]Session expired.[/red] Run [bold]pivotra login[/bold].")
187
+ sys.exit(1)
188
+ r.raise_for_status()
189
+ return r.json() if r.content else {"status": "deleted"}
190
+ except httpx.HTTPStatusError as e:
191
+ raise APIError(e.response.status_code, e.response.text[:200])
192
+ except httpx.RequestError as e:
193
+ raise APIError(0, str(e))
194
+
195
+
196
+ def restart_agent(agent_id: str) -> dict:
197
+ return _post(f"/api/v1/agents/{agent_id}/restart", {})
198
+
199
+
200
+ def rotate_key(agent_id: str) -> dict:
201
+ return _post(f"/api/v1/agents/{agent_id}/rotate-key", {})
202
+
203
+
204
+ # ── Alerts ────────────────────────────────────────────────────────────────────
205
+
206
+ def get_alerts(limit: int = 20) -> list[dict]:
207
+ data = _get("/api/v1/alerts/feed", params={"limit": limit})
208
+ if isinstance(data, list):
209
+ return data
210
+ return data.get("items") or data.get("alerts") or data.get("data") or []
211
+
212
+
213
+ # ── Fleet stats ───────────────────────────────────────────────────────────────
214
+
215
+ def fleet_stats() -> dict:
216
+ return _get("/api/v1/agents/fleet/stats")
217
+
218
+
219
+ def get_agent_timeline(agent_id: str, limit: int = 30) -> list[dict]:
220
+ data = _get(f"/api/v1/agents/{agent_id}/timeline", params={"limit": limit})
221
+ if isinstance(data, list):
222
+ return data
223
+ return data.get("items") or data.get("events") or []
224
+
225
+
226
+ def list_group_agents(group_id: str) -> list[dict]:
227
+ data = _get(f"/api/v1/groups/{group_id}/agents")
228
+ if isinstance(data, list):
229
+ return data
230
+ return data.get("items") or data.get("agents") or []
231
+
232
+
233
+ def get_2fa_status() -> dict:
234
+ return _get("/api/v1/auth/2fa/status")
235
+
236
+
237
+ def export_resource(resource: str, fmt: str = "json", limit: int = 100,
238
+ agent_id: Optional[str] = None) -> list[dict]:
239
+ endpoint_map = {
240
+ "agents": ("/api/v1/agents/", {}),
241
+ "alerts": ("/api/v1/alerts/", {"limit": limit}),
242
+ "anomalies": ("/api/v1/anomalies/", {"limit": limit}),
243
+ "iocs": ("/api/v1/intel/iocs", {"limit": limit}),
244
+ "audit": ("/api/v1/audit/", {"limit": limit}),
245
+ }
246
+ results = {}
247
+ if resource == "all":
248
+ for name, (path, params) in endpoint_map.items():
249
+ try:
250
+ data = _get(path, params=params)
251
+ results[name] = data if isinstance(data, list) else (data.get("items") or data.get("data") or [data])
252
+ except Exception:
253
+ results[name] = []
254
+ return results # type: ignore
255
+ path, params = endpoint_map.get(resource, (f"/api/v1/{resource}/", {"limit": limit}))
256
+ if agent_id:
257
+ params["agent_id"] = agent_id
258
+ data = _get(path, params=params)
259
+ if isinstance(data, list):
260
+ return data
261
+ return data.get("items") or data.get("data") or []
262
+
263
+
264
+ # ── Infra ops (file, docker, psql) ────────────────────────────────────────────
265
+
266
+ def file_read(agent_id: str, path: str) -> str:
267
+ result = run_command(agent_id, f"cat {path}")
268
+ return result.get("output") or result.get("result") or ""
269
+
270
+
271
+ def file_list(agent_id: str, path: str) -> str:
272
+ result = run_command(agent_id, f"ls -la {path}")
273
+ return result.get("output") or result.get("result") or ""
274
+
275
+
276
+ def file_write(agent_id: str, path: str, content: str) -> dict:
277
+ import shlex
278
+ safe_content = content.replace("'", "'\\''")
279
+ cmd = f"cat > {shlex.quote(path)} << 'PIVOTRA_EOF'\n{safe_content}\nPIVOTRA_EOF"
280
+ return run_command(agent_id, cmd)
281
+
282
+
283
+ def docker_cmd(agent_id: str, subcmd: str) -> str:
284
+ result = run_command(agent_id, f"docker {subcmd}")
285
+ return result.get("output") or result.get("result") or ""
286
+
287
+
288
+ def psql_query(agent_id: str, sql: str, dbname: str = "") -> str:
289
+ db_flag = f"-d {dbname}" if dbname else ""
290
+ result = run_command(agent_id, f'psql {db_flag} -c "{sql}"')
291
+ return result.get("output") or result.get("result") or ""
292
+
293
+
294
+ # ── Forensics & Hunt ──────────────────────────────────────────────────────────
295
+
296
+ def run_hunt(query: str) -> dict:
297
+ return _post("/api/v1/hunt/run-adhoc", {"query": query})
298
+
299
+
300
+ def get_hunt_jobs() -> list[dict]:
301
+ return _get("/api/v1/hunt/jobs")
302
+
303
+
304
+ def get_hunt_results(job_id: str) -> dict:
305
+ return _get(f"/api/v1/hunt/jobs/{job_id}")
306
+
307
+
308
+ def get_hunt_artifacts() -> list[dict]:
309
+ return _get("/api/v1/hunt/artifacts")
310
+
311
+
312
+ # ── Investigations ─────────────────────────────────────────────────────────────
313
+
314
+ def list_investigations(limit: int = 20) -> list[dict]:
315
+ data = _get("/api/v1/investigations/", params={"limit": limit})
316
+ if isinstance(data, list):
317
+ return data
318
+ return data.get("items") or data.get("data") or []
319
+
320
+
321
+ def get_investigation(case_id: str) -> dict:
322
+ return _get(f"/api/v1/investigations/{case_id}")
323
+
324
+
325
+ def trigger_investigation(anomaly_id: str) -> dict:
326
+ return _post("/api/v1/investigations/trigger", {"anomaly_id": anomaly_id})
327
+
328
+
329
+ # ── IOCs ───────────────────────────────────────────────────────────────────────
330
+
331
+ def lookup_ioc(value: str) -> dict:
332
+ return _get("/api/v1/intel/lookup", params={"q": value})
333
+
334
+
335
+ def list_iocs(limit: int = 20) -> list[dict]:
336
+ data = _get("/api/v1/intel/iocs", params={"limit": limit})
337
+ if isinstance(data, list):
338
+ return data
339
+ return data.get("items") or data.get("data") or []
340
+
341
+
342
+ def add_ioc(ioc_type: str, value: str, severity: str = "medium") -> dict:
343
+ return _post("/api/v1/intel/iocs", {
344
+ "type": ioc_type, "value": value, "severity": severity
345
+ })
346
+
347
+
348
+ # ── IP Reputation ──────────────────────────────────────────────────────────────
349
+
350
+ def check_ip(ip: str) -> dict:
351
+ return _get(f"/api/v1/ip-reputation/check/{ip}")
352
+
353
+
354
+ # ── Reports ────────────────────────────────────────────────────────────────────
355
+
356
+ def generate_report(days: int = 7) -> dict:
357
+ return _post("/api/v1/reports/generate", {"days": days})
358
+
359
+
360
+ def list_reports() -> list[dict]:
361
+ data = _get("/api/v1/reports/runs")
362
+ if isinstance(data, list):
363
+ return data
364
+ return data.get("items") or data.get("data") or []
365
+
366
+
367
+ def download_report(run_id: str, fmt: str = "pdf") -> bytes:
368
+ url = f"{_base()}/api/v1/reports/runs/{run_id}/export-{fmt}"
369
+ try:
370
+ with httpx.Client(timeout=60) as c:
371
+ r = c.get(url, headers=_headers())
372
+ r.raise_for_status()
373
+ return r.content
374
+ except httpx.HTTPStatusError as e:
375
+ raise APIError(e.response.status_code, e.response.text[:200])
376
+ except httpx.RequestError as e:
377
+ raise APIError(0, str(e))
378
+
379
+
380
+ # ── MITRE ATT&CK ──────────────────────────────────────────────────────────────
381
+
382
+ def get_mitre_coverage() -> dict:
383
+ return _get("/api/v1/mitre/coverage")
384
+
385
+
386
+ def get_mitre_stats() -> dict:
387
+ return _get("/api/v1/mitre/stats")
388
+
389
+
390
+ # ── EDR ────────────────────────────────────────────────────────────────────────
391
+
392
+ def get_edr_events(agent_id: Optional[str] = None, limit: int = 20) -> list[dict]:
393
+ params: dict = {"limit": limit}
394
+ if agent_id:
395
+ params["agent_id"] = agent_id
396
+ data = _get("/api/v1/edr/events", params=params)
397
+ if isinstance(data, list):
398
+ return data
399
+ return data.get("items") or data.get("data") or []
400
+
401
+
402
+ def mitigate_edr_event(event_id: str) -> dict:
403
+ url = f"{_base()}/api/v1/edr/events/{event_id}/mitigate"
404
+ try:
405
+ with httpx.Client(timeout=10) as c:
406
+ r = c.patch(url, headers=_headers(), json={})
407
+ r.raise_for_status()
408
+ return r.json()
409
+ except httpx.HTTPStatusError as e:
410
+ raise APIError(e.response.status_code, e.response.text[:200])
411
+ except httpx.RequestError as e:
412
+ raise APIError(0, str(e))
413
+
414
+
415
+ # ── Playbooks ─────────────────────────────────────────────────────────────────
416
+
417
+ def list_playbooks() -> list[dict]:
418
+ data = _get("/api/v1/playbooks/")
419
+ if isinstance(data, list):
420
+ return data
421
+ return data.get("items") or data.get("data") or []
422
+
423
+
424
+ def get_playbook_flow(playbook_id: str) -> dict:
425
+ return _get(f"/api/v1/playbooks/{playbook_id}/flow")
426
+
427
+
428
+ def run_playbook(playbook_id: str, target: str = "", dry_run: bool = False) -> dict:
429
+ return _post(f"/api/v1/playbooks/{playbook_id}/run-flow",
430
+ {"target": target, "dry_run": dry_run})
431
+
432
+
433
+ def get_playbook_log(action_id: str) -> dict:
434
+ return _get(f"/api/v1/playbooks/{action_id}/execution-log")
435
+
436
+
437
+ def get_external_runs(limit: int = 20) -> list[dict]:
438
+ data = _get("/api/v1/playbooks/external-executions", params={"limit": limit})
439
+ if isinstance(data, list):
440
+ return data
441
+ return data.get("items") or data.get("data") or []
442
+
443
+
444
+ def trigger_external_playbook(action_type: str, target: str,
445
+ reason: str = "", token: str = "") -> dict:
446
+ import httpx as _httpx
447
+ url = f"{_base()}/api/v1/playbooks/webhook-trigger"
448
+ headers = {"X-Webhook-Token": token} if token else {}
449
+ try:
450
+ with _httpx.Client(timeout=15) as c:
451
+ r = c.post(url, headers={**_headers(), **headers},
452
+ json={"action_type": action_type, "target": target, "reason": reason})
453
+ r.raise_for_status()
454
+ return r.json()
455
+ except _httpx.HTTPStatusError as e:
456
+ raise APIError(e.response.status_code, e.response.text[:200])
457
+ except _httpx.RequestError as e:
458
+ raise APIError(0, str(e))
459
+
460
+
461
+ # ── Response Queue ─────────────────────────────────────────────────────────────
462
+
463
+ def get_response_queue() -> list[dict]:
464
+ data = _get("/api/v1/response/queue")
465
+ if isinstance(data, list):
466
+ return data
467
+ return data.get("items") or data.get("data") or []
468
+
469
+
470
+ def approve_action(action_id: str) -> dict:
471
+ return _post(f"/api/v1/response/{action_id}/approve", {})
472
+
473
+
474
+ def reject_action(action_id: str) -> dict:
475
+ return _post(f"/api/v1/response/{action_id}/reject", {})
476
+
477
+
478
+ def get_response_stats() -> dict:
479
+ return _get("/api/v1/response/stats")
480
+
481
+
482
+ # ── Agent Groups ───────────────────────────────────────────────────────────────
483
+
484
+ def list_groups() -> list[dict]:
485
+ data = _get("/api/v1/groups")
486
+ if isinstance(data, list):
487
+ return data
488
+ return data.get("items") or data.get("data") or []
489
+
490
+
491
+ def create_group(name: str, description: str = "") -> dict:
492
+ return _post("/api/v1/agent-groups/", {"name": name, "description": description})
493
+
494
+
495
+ def assign_to_group(group_id: str, agent_ids: list[str]) -> dict:
496
+ return _post(f"/api/v1/agent-groups/{group_id}/assign", {"agent_ids": agent_ids})
497
+
498
+
499
+ # ── Audit ─────────────────────────────────────────────────────────────────────
500
+
501
+ def get_audit_logs(limit: int = 20) -> list[dict]:
502
+ data = _get("/api/v1/audit/logs", params={"limit": limit})
503
+ if isinstance(data, list):
504
+ return data
505
+ return data.get("items") or data.get("data") or []
506
+
507
+
508
+ # ── Users & Invitations ────────────────────────────────────────────────────────
509
+
510
+ def list_users() -> list[dict]:
511
+ data = _get("/api/v1/auth/users")
512
+ if isinstance(data, list):
513
+ return data
514
+ return data.get("items") or data.get("data") or []
515
+
516
+
517
+ def invite_user(email: str, role: str = "analyst") -> dict:
518
+ return _post("/api/v1/invitations/send", {"email": email, "role": role})
519
+
520
+
521
+ # ── Jobs ───────────────────────────────────────────────────────────────────────
522
+
523
+ def list_jobs() -> list[dict]:
524
+ data = _get("/api/v1/jobs/")
525
+ if isinstance(data, list):
526
+ return data
527
+ return data.get("items") or data.get("data") or []
528
+
529
+
530
+ # ── Dashboard ─────────────────────────────────────────────────────────────────
531
+
532
+ def get_dashboard() -> dict:
533
+ return _get("/api/v1/dashboard")
534
+
535
+
536
+ # ── Personal API tokens ───────────────────────────────────────────────────────
537
+
538
+ def list_tokens() -> list[dict]:
539
+ return _get("/api/v1/tokens") or []
540
+
541
+ def create_token(name: str, expires_days: Optional[int] = None) -> dict:
542
+ body: dict = {"name": name}
543
+ if expires_days:
544
+ body["expires_days"] = expires_days
545
+ return _post("/api/v1/tokens", body)
546
+
547
+ def revoke_token(token_id: int) -> dict:
548
+ return _delete(f"/api/v1/tokens/{token_id}")
549
+
550
+
551
+ # ── BRO Chat stream (SSE) ─────────────────────────────────────────────────────
552
+
553
+ def bro_stream(messages: list[dict], context: dict) -> httpx.Response:
554
+ """Returns a streaming response for BRO chat."""
555
+ url = f"{_base()}/api/v1/cli/bro/stream"
556
+ return httpx.stream(
557
+ "POST", url,
558
+ headers={**_headers(), "Accept": "text/event-stream"},
559
+ json={"messages": messages, "context": context},
560
+ timeout=120,
561
+ )