admina-framework 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. admina/__init__.py +34 -0
  2. admina/cli/__init__.py +14 -0
  3. admina/cli/commands/__init__.py +14 -0
  4. admina/cli/main.py +1522 -0
  5. admina/cli/templates/admina.yaml.j2 +77 -0
  6. admina/cli/templates/docker-compose.yml.j2 +254 -0
  7. admina/cli/templates/env.j2 +10 -0
  8. admina/cli/templates/main.py.j2 +95 -0
  9. admina/cli/templates/plugin.py.j2 +145 -0
  10. admina/cli/templates/plugin_pyproject.toml.j2 +15 -0
  11. admina/cli/templates/plugin_readme.md.j2 +27 -0
  12. admina/cli/templates/plugin_test.py.j2 +48 -0
  13. admina/core/__init__.py +14 -0
  14. admina/core/config.py +497 -0
  15. admina/core/event_bus.py +112 -0
  16. admina/core/secrets.py +257 -0
  17. admina/core/types.py +146 -0
  18. admina/dashboard/__init__.py +8 -0
  19. admina/dashboard/static/heimdall.png +0 -0
  20. admina/dashboard/static/index.html +1045 -0
  21. admina/dashboard/static/vendor/alpinejs.min.js +5 -0
  22. admina/domains/__init__.py +14 -0
  23. admina/domains/agent_security/__init__.py +41 -0
  24. admina/domains/agent_security/firewall.py +634 -0
  25. admina/domains/agent_security/loop_breaker.py +176 -0
  26. admina/domains/ai_infra/__init__.py +79 -0
  27. admina/domains/ai_infra/llm_engine.py +477 -0
  28. admina/domains/ai_infra/rag.py +817 -0
  29. admina/domains/ai_infra/webui.py +292 -0
  30. admina/domains/compliance/__init__.py +109 -0
  31. admina/domains/compliance/cross_regulation.py +314 -0
  32. admina/domains/compliance/eu_ai_act.py +367 -0
  33. admina/domains/compliance/forensic.py +380 -0
  34. admina/domains/compliance/gdpr.py +331 -0
  35. admina/domains/compliance/nis2.py +258 -0
  36. admina/domains/compliance/oisg.py +658 -0
  37. admina/domains/compliance/otel.py +101 -0
  38. admina/domains/data_sovereignty/__init__.py +42 -0
  39. admina/domains/data_sovereignty/classification.py +102 -0
  40. admina/domains/data_sovereignty/pii.py +260 -0
  41. admina/domains/data_sovereignty/residency.py +121 -0
  42. admina/integrations/__init__.py +14 -0
  43. admina/integrations/_engines.py +63 -0
  44. admina/integrations/cheshirecat/__init__.py +13 -0
  45. admina/integrations/cheshirecat/admina-plugin/admina_governance.py +207 -0
  46. admina/integrations/crewai/__init__.py +13 -0
  47. admina/integrations/crewai/callbacks.py +347 -0
  48. admina/integrations/langchain/__init__.py +13 -0
  49. admina/integrations/langchain/callbacks.py +341 -0
  50. admina/integrations/n8n/__init__.py +14 -0
  51. admina/integrations/openclaw/__init__.py +14 -0
  52. admina/plugins/__init__.py +49 -0
  53. admina/plugins/base.py +633 -0
  54. admina/plugins/builtin/__init__.py +14 -0
  55. admina/plugins/builtin/adapters/__init__.py +14 -0
  56. admina/plugins/builtin/adapters/ollama.py +120 -0
  57. admina/plugins/builtin/adapters/openai.py +138 -0
  58. admina/plugins/builtin/alerts/__init__.py +14 -0
  59. admina/plugins/builtin/alerts/log.py +66 -0
  60. admina/plugins/builtin/alerts/webhook.py +102 -0
  61. admina/plugins/builtin/auth/__init__.py +14 -0
  62. admina/plugins/builtin/auth/apikey.py +138 -0
  63. admina/plugins/builtin/compliance/__init__.py +14 -0
  64. admina/plugins/builtin/compliance/eu_ai_act.py +202 -0
  65. admina/plugins/builtin/connectors/__init__.py +14 -0
  66. admina/plugins/builtin/connectors/chromadb.py +137 -0
  67. admina/plugins/builtin/connectors/filesystem.py +111 -0
  68. admina/plugins/builtin/forensic/__init__.py +14 -0
  69. admina/plugins/builtin/forensic/filesystem.py +163 -0
  70. admina/plugins/builtin/forensic/minio.py +180 -0
  71. admina/plugins/builtin/guards/__init__.py +0 -0
  72. admina/plugins/builtin/guards/guardrailsai_guard.py +172 -0
  73. admina/plugins/builtin/pii/__init__.py +14 -0
  74. admina/plugins/builtin/pii/spacy_regex.py +160 -0
  75. admina/plugins/builtin/transports/__init__.py +14 -0
  76. admina/plugins/builtin/transports/http_rest.py +97 -0
  77. admina/plugins/builtin/transports/mcp.py +173 -0
  78. admina/plugins/registry.py +356 -0
  79. admina/proxy/__init__.py +15 -0
  80. admina/proxy/api/__init__.py +17 -0
  81. admina/proxy/api/dashboard.py +925 -0
  82. admina/proxy/api/integration.py +153 -0
  83. admina/proxy/config.py +214 -0
  84. admina/proxy/engine_bridge.py +306 -0
  85. admina/proxy/governance.py +232 -0
  86. admina/proxy/main.py +1484 -0
  87. admina/proxy/multi_upstream.py +156 -0
  88. admina/proxy/state.py +97 -0
  89. admina/py.typed +0 -0
  90. admina/sdk/__init__.py +34 -0
  91. admina/sdk/_compat.py +43 -0
  92. admina/sdk/compliance_kit.py +359 -0
  93. admina/sdk/governed_agent.py +391 -0
  94. admina/sdk/governed_data.py +434 -0
  95. admina/sdk/governed_model.py +241 -0
  96. admina_framework-0.9.0.dist-info/METADATA +575 -0
  97. admina_framework-0.9.0.dist-info/RECORD +102 -0
  98. admina_framework-0.9.0.dist-info/WHEEL +5 -0
  99. admina_framework-0.9.0.dist-info/entry_points.txt +2 -0
  100. admina_framework-0.9.0.dist-info/licenses/LICENSE +191 -0
  101. admina_framework-0.9.0.dist-info/licenses/NOTICE +16 -0
  102. admina_framework-0.9.0.dist-info/top_level.txt +1 -0
admina/cli/main.py ADDED
@@ -0,0 +1,1522 @@
1
+ # Copyright © 2025–2026 Stefano Noferi & Admina contributors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Admina CLI — project scaffolding and management commands.
16
+
17
+ Entry point: ``admina = "cli.main:app"`` in pyproject.toml.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import logging
24
+ import os
25
+ import shutil
26
+ import subprocess
27
+ import sys
28
+ import time
29
+ import webbrowser
30
+ from pathlib import Path
31
+ from urllib.error import URLError
32
+ from urllib.request import urlopen
33
+
34
+ import click
35
+ import yaml
36
+ from jinja2 import Environment, FileSystemLoader, select_autoescape
37
+
38
+ from admina import __version__
39
+ from admina.core.secrets import SecretVault, validate_password
40
+
41
+ _TEMPLATES_DIR = Path(__file__).parent / "templates"
42
+
43
+ # Domains the user can toggle on/off during ``admina init``.
44
+ AVAILABLE_DOMAINS: dict[str, str] = {
45
+ "data_sovereignty": "PII redaction, data residency, classification",
46
+ "ai_infra": "LLM engine, RAG pipeline, Web UI",
47
+ "agent_security": "MCP proxy, firewall, loop breaker",
48
+ "compliance": "Forensic black-box, EU AI Act, OTEL",
49
+ }
50
+
51
+ # Modules map to domains for the --modules shorthand.
52
+ MODULE_TO_DOMAIN: dict[str, str] = {
53
+ "model": "ai_infra",
54
+ "data": "data_sovereignty",
55
+ "compliance": "compliance",
56
+ "security": "agent_security",
57
+ }
58
+
59
+
60
+ def _bootstrap_secrets(project_dir: Path, *, force: bool = False) -> dict[str, str] | None:
61
+ """Auto-generate secrets on first launch. Returns secrets if generated."""
62
+ vault = SecretVault(project_dir)
63
+ if vault.is_initialized and not force:
64
+ return None
65
+
66
+ generated = vault.bootstrap()
67
+
68
+ # Write .env with vault secrets so docker compose can read them
69
+ vault.write_dotenv(project_dir / ".env")
70
+
71
+ click.echo()
72
+ click.echo(" " + "=" * 54)
73
+ click.echo(" First-boot credentials generated!")
74
+ click.echo()
75
+ click.echo(f" API Key: {generated['ADMINA_API_KEY']}")
76
+ click.echo(f" Password: {generated['ADMINA_DASHBOARD_PASSWORD']}")
77
+ click.echo(" (dashboard, Grafana, MinIO, ClickHouse)")
78
+ click.echo()
79
+ click.echo(" Save these now — they will NOT be shown again.")
80
+ click.echo(" View: admina password show")
81
+ click.echo(" Reset: admina password reset")
82
+ click.echo(" " + "=" * 54)
83
+ click.echo()
84
+ return generated
85
+
86
+
87
+ def _jinja_env() -> Environment:
88
+ """Create a Jinja2 environment pointing at the CLI templates dir.
89
+
90
+ Templates here are YAML / Python / .env files, not HTML, so XSS
91
+ isn't a risk. We still enable selective autoescape (html/xml) as
92
+ a defensive default in case a future template ships HTML.
93
+ """
94
+ return Environment(
95
+ loader=FileSystemLoader(str(_TEMPLATES_DIR)),
96
+ autoescape=select_autoescape(["html", "xml"]),
97
+ keep_trailing_newline=True,
98
+ )
99
+
100
+
101
+ def _prompt_domains() -> list[str]:
102
+ """Interactively ask the user which domains to enable."""
103
+ click.echo("\nAvailable domains:")
104
+ keys = list(AVAILABLE_DOMAINS.keys())
105
+ for i, (name, desc) in enumerate(AVAILABLE_DOMAINS.items(), 1):
106
+ click.echo(f" {i}. {name} — {desc}")
107
+ click.echo()
108
+ selection = click.prompt(
109
+ "Select domains (comma-separated numbers, or 'all')",
110
+ default="all",
111
+ )
112
+ if selection.strip().lower() == "all":
113
+ return keys
114
+ chosen: list[str] = []
115
+ for part in selection.split(","):
116
+ part = part.strip()
117
+ if part.isdigit():
118
+ idx = int(part) - 1
119
+ if 0 <= idx < len(keys):
120
+ chosen.append(keys[idx])
121
+ return chosen or keys
122
+
123
+
124
+ def _resolve_domains(
125
+ full_stack: bool,
126
+ modules: str | None,
127
+ interactive: bool = True,
128
+ ) -> list[str]:
129
+ """Determine which domains to enable based on CLI flags."""
130
+ if full_stack:
131
+ return list(AVAILABLE_DOMAINS.keys())
132
+ if modules:
133
+ domains: list[str] = []
134
+ for m in modules.split(","):
135
+ m = m.strip().lower()
136
+ domain = MODULE_TO_DOMAIN.get(m)
137
+ if domain and domain not in domains:
138
+ domains.append(domain)
139
+ return domains or list(AVAILABLE_DOMAINS.keys())
140
+ if interactive and sys.stdin.isatty():
141
+ return _prompt_domains()
142
+ return list(AVAILABLE_DOMAINS.keys())
143
+
144
+
145
+ def _generate_file(
146
+ env: Environment,
147
+ template_name: str,
148
+ output_path: Path,
149
+ context: dict[str, object],
150
+ ) -> None:
151
+ """Render a Jinja2 template and write it to *output_path*."""
152
+ tmpl = env.get_template(template_name)
153
+ content = tmpl.render(**context)
154
+ output_path.write_text(content)
155
+
156
+
157
+ def _scaffold_project(
158
+ project_dir: Path,
159
+ domains: list[str],
160
+ project_name: str,
161
+ ) -> list[str]:
162
+ """Generate the full project skeleton and return list of created files."""
163
+ env = _jinja_env()
164
+ created: list[str] = []
165
+
166
+ context: dict[str, object] = {
167
+ "project_name": project_name,
168
+ "domains": {d: d in domains for d in AVAILABLE_DOMAINS},
169
+ }
170
+
171
+ # admina.yaml
172
+ _generate_file(env, "admina.yaml.j2", project_dir / "admina.yaml", context)
173
+ created.append("admina.yaml")
174
+
175
+ # docker-compose.yml
176
+ _generate_file(
177
+ env,
178
+ "docker-compose.yml.j2",
179
+ project_dir / "docker-compose.yml",
180
+ context,
181
+ )
182
+ created.append("docker-compose.yml")
183
+
184
+ # .env with placeholder secrets
185
+ env_file = project_dir / ".env"
186
+ if not env_file.exists():
187
+ _generate_file(env, "env.j2", env_file, context)
188
+ created.append(".env")
189
+
190
+ # Example main.py
191
+ _generate_file(env, "main.py.j2", project_dir / "main.py", context)
192
+ created.append("main.py")
193
+
194
+ return created
195
+
196
+
197
+ @click.group()
198
+ @click.version_option(version=__version__, prog_name="admina")
199
+ def app() -> None:
200
+ """Admina — governed AI development framework."""
201
+
202
+
203
+ @app.command()
204
+ @click.argument("project_name", default="my-admina-project")
205
+ @click.option(
206
+ "--full-stack",
207
+ is_flag=True,
208
+ default=False,
209
+ help="Enable all domains.",
210
+ )
211
+ @click.option(
212
+ "--modules",
213
+ default=None,
214
+ help="Comma-separated modules: model, data, compliance, security.",
215
+ )
216
+ @click.option(
217
+ "--no-pull",
218
+ is_flag=True,
219
+ default=False,
220
+ help="Skip docker compose pull.",
221
+ )
222
+ def init(
223
+ project_name: str,
224
+ full_stack: bool,
225
+ modules: str | None,
226
+ no_pull: bool,
227
+ ) -> None:
228
+ """Scaffold a new Admina project.
229
+
230
+ Creates admina.yaml, docker-compose.yml, .env, and an example main.py
231
+ inside PROJECT_NAME directory.
232
+ """
233
+ # 1. Resolve domains
234
+ domains = _resolve_domains(full_stack, modules)
235
+
236
+ click.echo(f"\n Creating project: {project_name}")
237
+ click.echo(f" Domains: {', '.join(domains)}\n")
238
+
239
+ # 2. Create project directory
240
+ project_dir = Path.cwd() / project_name
241
+ project_dir.mkdir(parents=True, exist_ok=True)
242
+
243
+ # 3. Generate files
244
+ created = _scaffold_project(project_dir, domains, project_name)
245
+ for f in created:
246
+ click.echo(f" ✓ {f}")
247
+
248
+ # 4. Bootstrap secrets (first-time setup)
249
+ _bootstrap_secrets(project_dir)
250
+
251
+ # 5. Docker compose pull (optional)
252
+ if not no_pull and shutil.which("docker"):
253
+ click.echo("\n Pulling Docker images...")
254
+ result = subprocess.run(
255
+ ["docker", "compose", "pull"],
256
+ cwd=str(project_dir),
257
+ capture_output=True,
258
+ text=True,
259
+ )
260
+ if result.returncode == 0:
261
+ click.echo(" ✓ Docker images pulled")
262
+ else:
263
+ click.echo(" ⚠ docker compose pull failed (you can run it later)")
264
+
265
+ # 5. Print next steps
266
+ click.echo(f"""
267
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
268
+ Project ready!
269
+
270
+ Next steps:
271
+ cd {project_name}
272
+ admina dev # local mode (no Docker): proxy + dashboard on :3000
273
+ admina dev --stack # full Docker stack (proxy, redis, clickhouse, minio, grafana)
274
+ python main.py # run example (works without admina dev)
275
+
276
+ Docs: https://admina.org/docs
277
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
278
+ """)
279
+
280
+
281
+ # ── admina dev helpers ─────────────────────────────────────
282
+
283
+
284
+ # Services and their health-check URLs, keyed by compose service name.
285
+ SERVICE_ENDPOINTS: dict[str, dict[str, str]] = {
286
+ "proxy": {
287
+ "label": "Proxy API",
288
+ "url": "http://localhost:8080",
289
+ "health": "http://localhost:8080/health",
290
+ },
291
+ "dashboard": {
292
+ "label": "Dashboard",
293
+ "url": "http://localhost:3000",
294
+ "health": "http://localhost:3000/",
295
+ },
296
+ "grafana": {
297
+ "label": "Grafana",
298
+ "url": "http://localhost:3001",
299
+ "health": "http://localhost:3001/api/health",
300
+ },
301
+ }
302
+
303
+
304
+ def _load_admina_yaml(project_dir: Path) -> dict[str, object]:
305
+ """Load and return the parsed admina.yaml from *project_dir*.
306
+
307
+ Raises:
308
+ SystemExit: If the file does not exist.
309
+ """
310
+ yaml_path = project_dir / "admina.yaml"
311
+ if not yaml_path.is_file():
312
+ click.echo(
313
+ "ERROR: admina.yaml not found in current directory. Run 'admina init' first.",
314
+ err=True,
315
+ )
316
+ raise SystemExit(1)
317
+ with open(yaml_path) as fh:
318
+ data = yaml.safe_load(fh) or {}
319
+ return data
320
+
321
+
322
+ def _yaml_hash(project_dir: Path) -> str:
323
+ """Return a SHA-256 hex digest of admina.yaml for change detection."""
324
+ content = (project_dir / "admina.yaml").read_bytes()
325
+ return hashlib.sha256(content).hexdigest()
326
+
327
+
328
+ def _domains_from_yaml(data: dict[str, object]) -> list[str]:
329
+ """Extract enabled domain names from a parsed admina.yaml dict."""
330
+ domains_raw = data.get("domains", {})
331
+ if not isinstance(domains_raw, dict):
332
+ return list(AVAILABLE_DOMAINS.keys())
333
+ enabled: list[str] = []
334
+ for name in AVAILABLE_DOMAINS:
335
+ section = domains_raw.get(name, {})
336
+ if isinstance(section, dict) and section.get("enabled", False):
337
+ enabled.append(name)
338
+ return enabled
339
+
340
+
341
+ def _maybe_regenerate_compose(project_dir: Path, data: dict[str, object]) -> bool:
342
+ """Re-generate docker-compose.yml if admina.yaml has changed.
343
+
344
+ Writes a ``.admina_compose_hash`` marker to track the last YAML hash.
345
+ Returns True if the compose file was regenerated.
346
+ """
347
+ current_hash = _yaml_hash(project_dir)
348
+ hash_file = project_dir / ".admina_compose_hash"
349
+
350
+ if hash_file.is_file() and hash_file.read_text().strip() == current_hash:
351
+ return False
352
+
353
+ domains = _domains_from_yaml(data)
354
+ project_name = project_dir.name
355
+ env = _jinja_env()
356
+ context: dict[str, object] = {
357
+ "project_name": project_name,
358
+ "domains": {d: d in domains for d in AVAILABLE_DOMAINS},
359
+ "with_llm": True, # init scaffolds the full template; admina dev gates at runtime
360
+ }
361
+ _generate_file(env, "docker-compose.yml.j2", project_dir / "docker-compose.yml", context)
362
+ hash_file.write_text(current_hash)
363
+ return True
364
+
365
+
366
+ def _check_docker() -> bool:
367
+ """Return True if ``docker`` is available on PATH."""
368
+ return shutil.which("docker") is not None
369
+
370
+
371
+ def _health_check(
372
+ url: str,
373
+ *,
374
+ timeout: float = 30.0,
375
+ interval: float = 2.0,
376
+ ) -> bool:
377
+ """Poll *url* until it returns HTTP 2xx or *timeout* expires.
378
+
379
+ Returns True if the service became healthy, False on timeout.
380
+ """
381
+ deadline = time.monotonic() + timeout
382
+ while time.monotonic() < deadline:
383
+ try:
384
+ resp = urlopen(url, timeout=3) # noqa: S310 — trusted localhost URL
385
+ if 200 <= resp.status < 400:
386
+ return True
387
+ except (URLError, OSError, TimeoutError):
388
+ pass
389
+ time.sleep(interval)
390
+ return False
391
+
392
+
393
+ def _wait_for_services(
394
+ services: list[dict[str, str]],
395
+ timeout: float = 30.0,
396
+ interval: float = 2.0,
397
+ ) -> list[dict[str, object]]:
398
+ """Poll health endpoints for each service.
399
+
400
+ Returns a list of dicts with keys ``label``, ``url``, ``healthy``.
401
+ """
402
+ results: list[dict[str, object]] = []
403
+ for svc in services:
404
+ label = svc["label"]
405
+ click.echo(f" Waiting for {label}...", nl=False)
406
+ healthy = _health_check(svc["health"], timeout=timeout, interval=interval)
407
+ if healthy:
408
+ click.echo(" ready")
409
+ else:
410
+ click.echo(" timeout")
411
+ results.append({"label": label, "url": svc["url"], "healthy": healthy})
412
+ return results
413
+
414
+
415
+ def _print_dev_summary(results: list[dict[str, object]]) -> None:
416
+ """Print a summary table of running services and next steps."""
417
+ click.echo("\n Admina development stack is running\n")
418
+ click.echo(" Services:")
419
+ for r in results:
420
+ status = "ready" if r["healthy"] else "unhealthy"
421
+ click.echo(f" {r['label']:<20s} {r['url']} ({status})")
422
+ click.echo("""
423
+ Logs:
424
+ docker compose logs -f
425
+
426
+ Stop:
427
+ docker compose down
428
+
429
+ Next steps:
430
+ Dashboard: http://localhost:3000
431
+ API docs: http://localhost:8080/docs
432
+ Health: curl http://localhost:8080/health
433
+ """)
434
+
435
+
436
+ def _find_free_port(preferred: int, host: str, search_window: int = 10) -> int:
437
+ """Return *preferred* if it can be bound on *host*, else the next free
438
+ port in ``[preferred+1, preferred+search_window-1]``.
439
+
440
+ Raises:
441
+ RuntimeError: If no port in the window is free.
442
+ """
443
+ import socket as _socket
444
+
445
+ bind_host = "127.0.0.1" if host in ("0.0.0.0", "::", "::0") else host
446
+ for port in range(preferred, preferred + search_window):
447
+ try:
448
+ with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as s:
449
+ # Don't set SO_REUSEADDR — we want a strict "is this port
450
+ # already serving something" check, not a "could be shared" one.
451
+ s.bind((bind_host, port))
452
+ return port
453
+ except OSError:
454
+ continue
455
+ raise RuntimeError(
456
+ f"No free port in [{preferred}, {preferred + search_window - 1}] on {bind_host}"
457
+ )
458
+
459
+
460
+ def _list_local_ipv4() -> list[str]:
461
+ """Return all IPv4 addresses reachable on this host (best-effort, stdlib only).
462
+
463
+ Always includes 127.0.0.1. Additionally probes the default-route address
464
+ (via UDP-connect trick) and `socket.gethostbyname_ex`, which together
465
+ cover the common cases: LAN IP, hostname-mapped IPs, multi-NIC setups.
466
+ """
467
+ import socket as _socket
468
+
469
+ ips: set[str] = {"127.0.0.1"}
470
+ try:
471
+ with _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM) as s:
472
+ s.settimeout(0.5)
473
+ s.connect(("8.8.8.8", 80))
474
+ ips.add(s.getsockname()[0])
475
+ except OSError:
476
+ pass
477
+ try:
478
+ ips.update(_socket.gethostbyname_ex(_socket.gethostname())[2])
479
+ except (_socket.herror, _socket.gaierror, OSError):
480
+ pass
481
+ # Sort: loopback first, then lexicographic
482
+ return sorted(ips, key=lambda ip: (ip != "127.0.0.1", ip))
483
+
484
+
485
+ def _run_local(
486
+ project_dir: Path,
487
+ vault: SecretVault,
488
+ *,
489
+ no_browser: bool,
490
+ port: int,
491
+ host: str,
492
+ ) -> None:
493
+ """Run proxy + dashboard as a single uvicorn process (no Docker)."""
494
+ # Auto-detect free port: if preferred is taken (Docker Desktop, Grafana,
495
+ # another node dev server on :3000…), fall back to the next free port.
496
+ try:
497
+ actual_port = _find_free_port(port, host)
498
+ except RuntimeError as exc:
499
+ click.echo(f"ERROR: {exc}", err=True)
500
+ raise SystemExit(1) from exc
501
+ if actual_port != port:
502
+ click.echo(f" ⚠ Port {port} is already in use — falling back to {actual_port}.")
503
+ port = actual_port
504
+
505
+ env = os.environ.copy()
506
+ env.update(vault.export_env())
507
+ # Local dev defaults — sane for single-user localhost.
508
+ env.setdefault("FORENSIC_BACKEND", "memory")
509
+ env.setdefault("OTEL_ENDPOINT", "")
510
+ env.setdefault("REDIS_URL", "")
511
+ env.setdefault("CLICKHOUSE_HOST", "")
512
+ env.setdefault("MINIO_ENDPOINT", "")
513
+ env.setdefault("UPSTREAM_MCP_URL", "")
514
+ env.setdefault("LOG_LEVEL", "INFO")
515
+
516
+ cmd = [
517
+ sys.executable,
518
+ "-m",
519
+ "uvicorn",
520
+ "admina.proxy.main:app",
521
+ "--host",
522
+ host,
523
+ "--port",
524
+ str(port),
525
+ "--log-level",
526
+ "info",
527
+ ]
528
+
529
+ # Display URL: if listening on all interfaces, prefer localhost for the
530
+ # banner but warn that the proxy is exposed to the LAN.
531
+ is_public = host in ("0.0.0.0", "::", "::0")
532
+ display_host = "localhost" if host in ("127.0.0.1", "0.0.0.0", "::", "::0") else host
533
+
534
+ click.echo(f"\n Starting Admina proxy + dashboard on http://{display_host}:{port}")
535
+ click.echo(" Mode: local (no Docker)")
536
+ click.echo(" Forensic backend: in-memory (events live for the process lifetime)")
537
+ if is_public:
538
+ click.echo(
539
+ f" ⚠ Listening on {host}:{port} — accessible from the LAN. "
540
+ "Auth: API key required for /api/*."
541
+ )
542
+ click.echo(" Stop with Ctrl+C\n")
543
+
544
+ proc = subprocess.Popen(cmd, cwd=str(project_dir), env=env)
545
+
546
+ # Wait for /health to become reachable, then open the browser
547
+ healthcheck_host = "127.0.0.1" if host in ("0.0.0.0", "127.0.0.1") else host
548
+ healthy = _health_check(f"http://{healthcheck_host}:{port}/health", timeout=15.0, interval=0.5)
549
+ if healthy:
550
+ # When bound to all interfaces, enumerate each reachable address.
551
+ # Otherwise show the single configured host.
552
+ if is_public:
553
+ click.echo(" Ready. Reachable URLs:")
554
+ for ip in _list_local_ipv4():
555
+ label = "localhost" if ip == "127.0.0.1" else "LAN"
556
+ click.echo(f" http://{ip}:{port} ({label})")
557
+ else:
558
+ click.echo(f" Ready → http://{display_host}:{port}")
559
+ if not no_browser:
560
+ webbrowser.open(f"http://{display_host}:{port}")
561
+ else:
562
+ click.echo(" WARNING: proxy did not become healthy within 15s — continuing")
563
+
564
+ try:
565
+ proc.wait()
566
+ except KeyboardInterrupt:
567
+ click.echo("\n Stopping...")
568
+ proc.terminate()
569
+ try:
570
+ proc.wait(timeout=5)
571
+ except subprocess.TimeoutExpired:
572
+ proc.kill()
573
+
574
+
575
+ def _run_compose(
576
+ project_dir: Path,
577
+ data: dict[str, object],
578
+ vault: SecretVault,
579
+ *,
580
+ no_browser: bool,
581
+ no_build: bool,
582
+ detach: bool,
583
+ with_llm: bool,
584
+ ) -> None:
585
+ """Run the Docker compose stack."""
586
+ if not _check_docker():
587
+ click.echo(
588
+ "ERROR: --stack requires Docker. Install Docker Desktop, "
589
+ "or run `admina dev` without --stack for the local mode.",
590
+ err=True,
591
+ )
592
+ raise SystemExit(1)
593
+
594
+ # Regenerate compose if admina.yaml hash changed; force regenerate if
595
+ # the with-llm tier toggles (track it in the hash so cache invalidates).
596
+ current_hash = _yaml_hash(project_dir) + (":with_llm" if with_llm else ":stack")
597
+ hash_file = project_dir / ".admina_compose_hash"
598
+ regen_needed = not hash_file.is_file() or hash_file.read_text().strip() != current_hash
599
+ if regen_needed:
600
+ domains = _domains_from_yaml(data)
601
+ project_name = project_dir.name
602
+ env_j = _jinja_env()
603
+ context: dict[str, object] = {
604
+ "project_name": project_name,
605
+ "domains": {d: d in domains for d in AVAILABLE_DOMAINS},
606
+ "with_llm": with_llm,
607
+ }
608
+ _generate_file(env_j, "docker-compose.yml.j2", project_dir / "docker-compose.yml", context)
609
+ hash_file.write_text(current_hash)
610
+ click.echo(" docker-compose.yml regenerated")
611
+ else:
612
+ click.echo(" docker-compose.yml is up to date")
613
+ domains = _domains_from_yaml(data)
614
+
615
+ compose_cmd: list[str] = ["docker", "compose", "up"]
616
+ if not no_build:
617
+ compose_cmd.append("--build")
618
+ if detach:
619
+ compose_cmd.append("-d")
620
+
621
+ compose_env = os.environ.copy()
622
+ compose_env.update(vault.export_env())
623
+
624
+ click.echo(f" Running: {' '.join(compose_cmd)}\n")
625
+
626
+ # Always start in detached mode internally so we can health-check.
627
+ detach_cmd = compose_cmd if detach else compose_cmd + ["-d"]
628
+ result = subprocess.run(
629
+ detach_cmd,
630
+ cwd=str(project_dir),
631
+ env=compose_env,
632
+ capture_output=True,
633
+ text=True,
634
+ )
635
+ if result.returncode != 0:
636
+ click.echo(
637
+ f"ERROR: docker compose up failed.\n{result.stderr}\nTry: docker compose logs",
638
+ err=True,
639
+ )
640
+ raise SystemExit(1)
641
+
642
+ active_services: list[dict[str, str]] = []
643
+ for svc_name, svc_info in SERVICE_ENDPOINTS.items():
644
+ if svc_name == "proxy" and "agent_security" not in domains:
645
+ continue
646
+ if svc_name == "grafana" and "compliance" not in domains:
647
+ continue
648
+ active_services.append(svc_info)
649
+
650
+ click.echo()
651
+ results = _wait_for_services(active_services)
652
+
653
+ if not no_browser:
654
+ webbrowser.open("http://localhost:3000")
655
+
656
+ _print_dev_summary(results)
657
+
658
+ if not detach:
659
+ click.echo(" Attaching to logs (Ctrl+C to stop)...\n")
660
+ try:
661
+ subprocess.run(["docker", "compose", "logs", "-f"], cwd=str(project_dir))
662
+ except KeyboardInterrupt:
663
+ click.echo("\n Stopping...")
664
+
665
+
666
+ @app.command()
667
+ @click.option(
668
+ "--stack",
669
+ is_flag=True,
670
+ default=False,
671
+ help="Run the full Docker stack (proxy + dashboard + redis + clickhouse + minio + otel + grafana).",
672
+ )
673
+ @click.option(
674
+ "--with-llm",
675
+ is_flag=True,
676
+ default=False,
677
+ help="Add local LLM services (ollama + chromadb + open-webui) to --stack. Implies --stack.",
678
+ )
679
+ @click.option("--no-browser", is_flag=True, default=False, help="Skip opening browser.")
680
+ @click.option("--no-build", is_flag=True, default=False, help="Use existing images (--stack only).")
681
+ @click.option("--detach", is_flag=True, default=False, help="Run in background (--stack only).")
682
+ @click.option(
683
+ "--port",
684
+ type=int,
685
+ default=3000,
686
+ help="Port for local mode (default 3000 — same as Docker stack dashboard).",
687
+ )
688
+ @click.option(
689
+ "--host",
690
+ type=str,
691
+ default="127.0.0.1",
692
+ help=(
693
+ "Bind address for local mode (default 127.0.0.1). "
694
+ "Use 0.0.0.0 to listen on all interfaces (LAN access)."
695
+ ),
696
+ )
697
+ @click.option(
698
+ "--public",
699
+ is_flag=True,
700
+ default=False,
701
+ help="Shortcut for --host 0.0.0.0 (listen on all interfaces).",
702
+ )
703
+ def dev(
704
+ stack: bool,
705
+ with_llm: bool,
706
+ no_browser: bool,
707
+ no_build: bool,
708
+ detach: bool,
709
+ port: int,
710
+ host: str,
711
+ public: bool,
712
+ ) -> None:
713
+ """Start Admina locally.
714
+
715
+ Three modes:
716
+
717
+ \b
718
+ admina dev Local mode (default): one uvicorn process,
719
+ dashboard served on the same port. No Docker.
720
+ admina dev --stack Docker compose: proxy + dashboard + redis +
721
+ clickhouse + minio + otel + grafana.
722
+ admina dev --with-llm --stack plus local LLM services
723
+ (ollama + chromadb + open-webui).
724
+
725
+ Local-mode network binding:
726
+
727
+ \b
728
+ admina dev → 127.0.0.1:3000 (localhost only)
729
+ admina dev --host 0.0.0.0 → all interfaces (LAN access)
730
+ admina dev --public → shortcut for --host 0.0.0.0
731
+ admina dev --port 9000 → custom port
732
+ """
733
+ project_dir = Path.cwd()
734
+
735
+ # Common bootstrap: admina.yaml + secrets vault
736
+ data = _load_admina_yaml(project_dir)
737
+ click.echo(" admina.yaml loaded")
738
+
739
+ vault = SecretVault(project_dir)
740
+ if not vault.is_initialized:
741
+ _bootstrap_secrets(project_dir)
742
+ else:
743
+ click.echo(" Secrets vault loaded")
744
+ vault.write_dotenv(project_dir / ".env")
745
+
746
+ if stack or with_llm:
747
+ _run_compose(
748
+ project_dir,
749
+ data,
750
+ vault,
751
+ no_browser=no_browser,
752
+ no_build=no_build,
753
+ detach=detach,
754
+ with_llm=with_llm,
755
+ )
756
+ else:
757
+ bind_host = "0.0.0.0" if public else host
758
+ _run_local(
759
+ project_dir,
760
+ vault,
761
+ no_browser=no_browser,
762
+ port=port,
763
+ host=bind_host,
764
+ )
765
+
766
+
767
+ # ── admina plugin helpers ──────────────────────────────────
768
+
769
+
770
+ # Maps --type flag values to the registry type keys.
771
+ PLUGIN_TYPE_CHOICES: list[str] = [
772
+ "model_adapter",
773
+ "data_connector",
774
+ "governance_guard",
775
+ "compliance_template",
776
+ "transport_adapter",
777
+ "forensic_store",
778
+ "auth_provider",
779
+ "pii_engine",
780
+ "alert_channel",
781
+ ]
782
+
783
+ # Maps type key → (base class name, name property, category dir)
784
+ _SCAFFOLD_META: dict[str, tuple[str, str, str]] = {
785
+ "model_adapter": ("BaseModelAdapter", "name", "adapters"),
786
+ "data_connector": ("BaseDataConnector", "name", "connectors"),
787
+ "governance_guard": ("BaseGovernanceGuard", "name", "guards"),
788
+ "compliance_template": ("BaseComplianceTemplate", "framework_name", "compliance"),
789
+ "transport_adapter": ("BaseTransportAdapter", "protocol_name", "transports"),
790
+ "forensic_store": ("BaseForensicStore", "store_name", "forensic"),
791
+ "auth_provider": ("BaseAuthProvider", "provider_name", "auth"),
792
+ "pii_engine": ("BasePIIEngine", "supported_languages", "pii"),
793
+ "alert_channel": ("BaseAlertChannel", "channel_name", "alerts"),
794
+ }
795
+
796
+
797
+ def _pip_install(package: str) -> subprocess.CompletedProcess[str]:
798
+ """Run ``pip install <package>`` and return the result."""
799
+ return subprocess.run(
800
+ [sys.executable, "-m", "pip", "install", package],
801
+ capture_output=True,
802
+ text=True,
803
+ )
804
+
805
+
806
+ def _discover_and_list_plugins() -> dict[str, dict[str, type]]:
807
+ """Run plugin discovery and return all registered plugins by type."""
808
+ from admina.plugins.registry import PluginRegistry
809
+
810
+ registry = PluginRegistry()
811
+ registry.discover()
812
+ return registry.list_all()
813
+
814
+
815
+ def _scaffold_plugin(
816
+ plugin_name: str,
817
+ plugin_type: str,
818
+ output_dir: Path,
819
+ ) -> list[str]:
820
+ """Generate boilerplate files for a new plugin.
821
+
822
+ Returns list of created file paths (relative to *output_dir*).
823
+ """
824
+ base_class, name_prop, _category = _SCAFFOLD_META[plugin_type]
825
+ class_name = "".join(w.capitalize() for w in plugin_name.replace("-", "_").split("_"))
826
+
827
+ output_dir.mkdir(parents=True, exist_ok=True)
828
+
829
+ env = _jinja_env()
830
+ context: dict[str, object] = {
831
+ "plugin_name": plugin_name,
832
+ "plugin_type": plugin_type,
833
+ "base_class": base_class,
834
+ "class_name": class_name,
835
+ "name_property": name_prop,
836
+ }
837
+
838
+ created: list[str] = []
839
+
840
+ # Main plugin module
841
+ plugin_file = output_dir / f"{plugin_name.replace('-', '_')}.py"
842
+ _generate_file(env, "plugin.py.j2", plugin_file, context)
843
+ created.append(plugin_file.name)
844
+
845
+ # Test file
846
+ tests_dir = output_dir / "tests"
847
+ tests_dir.mkdir(exist_ok=True)
848
+ test_file = tests_dir / f"test_{plugin_name.replace('-', '_')}.py"
849
+ _generate_file(env, "plugin_test.py.j2", test_file, context)
850
+ created.append(f"tests/{test_file.name}")
851
+
852
+ # pyproject.toml
853
+ pyproject_file = output_dir / "pyproject.toml"
854
+ _generate_file(env, "plugin_pyproject.toml.j2", pyproject_file, context)
855
+ created.append("pyproject.toml")
856
+
857
+ # README
858
+ readme_file = output_dir / "README.md"
859
+ _generate_file(env, "plugin_readme.md.j2", readme_file, context)
860
+ created.append("README.md")
861
+
862
+ return created
863
+
864
+
865
+ @app.group()
866
+ def plugin() -> None:
867
+ """Manage Admina plugins."""
868
+
869
+
870
+ @plugin.command("install")
871
+ @click.argument("package_name")
872
+ def plugin_install(package_name: str) -> None:
873
+ """Install an Admina plugin via pip and register it.
874
+
875
+ PACKAGE_NAME is a pip-installable package (e.g. admina-adapter-bedrock).
876
+ """
877
+ click.echo(f" Installing {package_name}...")
878
+ result = _pip_install(package_name)
879
+
880
+ if result.returncode != 0:
881
+ click.echo(f" ERROR: pip install failed.\n{result.stderr}", err=True)
882
+ raise SystemExit(1)
883
+
884
+ click.echo(f" Installed {package_name}")
885
+
886
+ # Add to admina.yaml plugins list if present
887
+ yaml_path = Path.cwd() / "admina.yaml"
888
+ if yaml_path.is_file():
889
+ data = yaml.safe_load(yaml_path.read_text()) or {}
890
+ plugins = data.get("plugins", [])
891
+ if not isinstance(plugins, list):
892
+ plugins = []
893
+ module_name = package_name.replace("-", "_")
894
+ if module_name not in plugins:
895
+ plugins.append(module_name)
896
+ data["plugins"] = plugins
897
+ yaml_path.write_text(yaml.dump(data, default_flow_style=False, sort_keys=False))
898
+ click.echo(f" Added {module_name} to admina.yaml plugins list")
899
+
900
+ click.echo(f"\n Plugin {package_name} is ready to use.")
901
+
902
+
903
+ def _plugin_install_path(cls: type) -> str:
904
+ """Return the source file path of a plugin class.
905
+
906
+ Falls back to ``cls.__module__`` if the file cannot be resolved
907
+ (e.g., dynamic classes or zip-imports).
908
+ """
909
+ import inspect
910
+
911
+ try:
912
+ path = Path(inspect.getfile(cls)).resolve()
913
+ except (TypeError, OSError):
914
+ return f"<module {cls.__module__}>"
915
+ # Make paths under the installed admina package readable as
916
+ # "admina/plugins/.../foo.py" rather than absolute site-packages.
917
+ try:
918
+ admina_pkg_root = Path(__file__).resolve().parents[1]
919
+ return str(path.relative_to(admina_pkg_root.parent))
920
+ except ValueError:
921
+ return str(path)
922
+
923
+
924
+ @plugin.command("list")
925
+ def plugin_list() -> None:
926
+ """List all installed Admina plugins by type, with their source path."""
927
+ all_plugins = _discover_and_list_plugins()
928
+ total = 0
929
+
930
+ click.echo("\n Installed plugins:\n")
931
+ for type_key in PLUGIN_TYPE_CHOICES:
932
+ plugins = all_plugins.get(type_key, {})
933
+ if plugins:
934
+ label = type_key.replace("_", " ").title()
935
+ click.echo(f" {label}:")
936
+ for name, cls in sorted(plugins.items()):
937
+ path = _plugin_install_path(cls)
938
+ click.echo(f" - {name}")
939
+ click.echo(f" module: {cls.__module__}")
940
+ click.echo(f" path: {path}")
941
+ total += 1
942
+ click.echo()
943
+
944
+ if total == 0:
945
+ click.echo(" No plugins found.")
946
+ else:
947
+ click.echo(f" Total: {total} plugin(s)")
948
+
949
+
950
+ @plugin.command("create")
951
+ @click.argument("plugin_name")
952
+ @click.option(
953
+ "--type",
954
+ "plugin_type",
955
+ type=click.Choice(PLUGIN_TYPE_CHOICES),
956
+ default="model_adapter",
957
+ help="Plugin type to scaffold.",
958
+ )
959
+ def plugin_create(plugin_name: str, plugin_type: str) -> None:
960
+ """Scaffold boilerplate for a new Admina plugin.
961
+
962
+ Creates a directory with the plugin module, tests, pyproject.toml,
963
+ and README.
964
+ """
965
+ output_dir = Path.cwd() / plugin_name
966
+
967
+ if output_dir.exists() and any(output_dir.iterdir()):
968
+ click.echo(f" ERROR: Directory {plugin_name}/ already exists and is not empty.", err=True)
969
+ raise SystemExit(1)
970
+
971
+ click.echo(f"\n Scaffolding plugin: {plugin_name}")
972
+ click.echo(f" Type: {plugin_type}\n")
973
+
974
+ created = _scaffold_plugin(plugin_name, plugin_type, output_dir)
975
+ for f in created:
976
+ click.echo(f" {f}")
977
+
978
+ click.echo(f"""
979
+ Plugin scaffolded in {plugin_name}/
980
+
981
+ Next steps:
982
+ cd {plugin_name}
983
+ # Edit {created[0]} to implement your plugin
984
+ pip install -e .
985
+ admina plugin list
986
+ """)
987
+
988
+
989
+ @app.command()
990
+ def doctor() -> None:
991
+ """Check Admina installation health.
992
+
993
+ Verifies Python version, core dependencies, optional extras,
994
+ governance engines, and infrastructure connectivity.
995
+ """
996
+ import platform
997
+
998
+ ok_mark = "[OK]"
999
+ warn_mark = "[WARN]"
1000
+ fail_mark = "[FAIL]"
1001
+ issues: list[str] = []
1002
+
1003
+ click.echo("")
1004
+ click.echo("=" * 55)
1005
+ click.echo(" Admina Doctor — Installation Health Check")
1006
+ click.echo("=" * 55)
1007
+
1008
+ # ── Python version ───────────────────────────────────────
1009
+ py_ver = platform.python_version()
1010
+ py_ok = tuple(int(x) for x in py_ver.split(".")[:2]) >= (3, 10)
1011
+ click.echo(f"\n Python: {py_ver} {ok_mark if py_ok else fail_mark}")
1012
+ if not py_ok:
1013
+ issues.append("Python >= 3.10 required")
1014
+
1015
+ # ── Admina version ───────────────────────────────────────
1016
+ try:
1017
+ from admina import __version__
1018
+
1019
+ click.echo(f" Admina: {__version__} {ok_mark}")
1020
+ except ImportError:
1021
+ click.echo(f" Admina: not installed {fail_mark}")
1022
+ issues.append("Run: pip install -e .")
1023
+
1024
+ # ── Core deps ────────────────────────────────────────────
1025
+ click.echo("\n Core dependencies:")
1026
+ for mod_name, pkg_name in [("yaml", "pyyaml"), ("click", "click"), ("jinja2", "jinja2")]:
1027
+ try:
1028
+ __import__(mod_name)
1029
+ click.echo(f" {pkg_name:20s} {ok_mark}")
1030
+ except ImportError:
1031
+ click.echo(f" {pkg_name:20s} {fail_mark}")
1032
+ issues.append(f"Missing: pip install {pkg_name}")
1033
+
1034
+ # ── Optional extras ──────────────────────────────────────
1035
+ click.echo("\n Optional extras:")
1036
+ extras = {
1037
+ "proxy": [
1038
+ ("fastapi", "fastapi"),
1039
+ ("uvicorn", "uvicorn"),
1040
+ ("httpx", "httpx"),
1041
+ ("pydantic", "pydantic"),
1042
+ ("redis", "redis"),
1043
+ ("minio", "minio"),
1044
+ ("clickhouse_connect", "clickhouse-connect"),
1045
+ ],
1046
+ "nlp": [
1047
+ ("spacy", "spacy"),
1048
+ ("sklearn", "scikit-learn"),
1049
+ ("numpy", "numpy"),
1050
+ ],
1051
+ "telemetry": [
1052
+ ("opentelemetry", "opentelemetry-api"),
1053
+ ],
1054
+ }
1055
+ for group, mods in extras.items():
1056
+ present = 0
1057
+ for mod_name, _ in mods:
1058
+ try:
1059
+ __import__(mod_name)
1060
+ present += 1
1061
+ except ImportError:
1062
+ pass
1063
+ if present == len(mods):
1064
+ click.echo(f" [{group}]{' ' * (16 - len(group))} {ok_mark} ({present}/{len(mods)})")
1065
+ elif present > 0:
1066
+ click.echo(
1067
+ f" [{group}]{' ' * (16 - len(group))} {warn_mark} ({present}/{len(mods)})"
1068
+ )
1069
+ else:
1070
+ click.echo(f" [{group}]{' ' * (16 - len(group))} -- not installed")
1071
+
1072
+ # ── spaCy NER model ──────────────────────────────────────
1073
+ click.echo("\n NLP engine:")
1074
+ try:
1075
+ import spacy # type: ignore[import-untyped]
1076
+
1077
+ try:
1078
+ spacy.load("en_core_web_sm")
1079
+ click.echo(f" en_core_web_sm {ok_mark}")
1080
+ except OSError:
1081
+ click.echo(
1082
+ f" en_core_web_sm {warn_mark} (run: python -m spacy download en_core_web_sm)"
1083
+ )
1084
+ issues.append("spaCy model not installed — PII uses regex-only mode")
1085
+ except ImportError:
1086
+ click.echo(" spacy -- not installed (PII disabled)")
1087
+
1088
+ # ── Rust engine ──────────────────────────────────────────
1089
+ click.echo("\n Governance engine:")
1090
+ try:
1091
+ import admina_core # type: ignore[import-untyped]
1092
+
1093
+ click.echo(f" Rust engine {ok_mark} v{admina_core.version()}")
1094
+ except ImportError:
1095
+ click.echo(" Rust engine -- not installed (using Python fallback)")
1096
+ click.echo(f" Python fallback {ok_mark}")
1097
+
1098
+ # ── Plugin registry ──────────────────────────────────────
1099
+ click.echo("\n Plugin registry:")
1100
+ try:
1101
+ from admina.plugins.registry import PluginRegistry
1102
+
1103
+ reg = PluginRegistry()
1104
+ count = reg.discover()
1105
+ click.echo(f" Discovered {ok_mark} {count} plugins")
1106
+ except Exception as exc:
1107
+ import traceback
1108
+
1109
+ logging.getLogger("admina.cli").debug(
1110
+ "Plugin discovery traceback:\n%s", traceback.format_exc()
1111
+ )
1112
+ click.echo(f" Discovered {fail_mark} {exc}")
1113
+ issues.append(f"Plugin discovery failed: {exc}")
1114
+
1115
+ # ── Environment variables ────────────────────────────────
1116
+ click.echo("\n Environment:")
1117
+ # Layer 1: process env. Layer 2: a .env file in cwd (the project's
1118
+ # vault-generated file). Process env wins so a deliberate override
1119
+ # is honoured.
1120
+ env = dict(os.environ)
1121
+ dotenv_loaded = False
1122
+ dotenv_path = Path.cwd() / ".env"
1123
+ if dotenv_path.is_file():
1124
+ try:
1125
+ for line in dotenv_path.read_text(encoding="utf-8").splitlines():
1126
+ line = line.strip()
1127
+ if not line or line.startswith("#") or "=" not in line:
1128
+ continue
1129
+ k, _, v = line.partition("=")
1130
+ k = k.strip()
1131
+ v = v.strip().strip('"').strip("'")
1132
+ env.setdefault(k, v)
1133
+ dotenv_loaded = True
1134
+ except OSError:
1135
+ pass
1136
+ if dotenv_loaded:
1137
+ click.echo(" (loaded ./.env)")
1138
+
1139
+ env_checks = [
1140
+ ("ADMINA_API_KEY", True, "auth disabled — set a strong key in production"),
1141
+ ("ADMINA_GOVERNANCE_MODE", False, "default 'enforce'"),
1142
+ ("ADMINA_DASHBOARD_PASSWORD", False, "dashboard basic-auth disabled"),
1143
+ ]
1144
+ for var, required, hint in env_checks:
1145
+ val = env.get(var, "")
1146
+ if val:
1147
+ shown = val if len(val) <= 8 else f"{val[:4]}…({len(val)} chars)"
1148
+ click.echo(f" {var:30s} {ok_mark} {shown}")
1149
+ else:
1150
+ mark = warn_mark if not required else fail_mark
1151
+ click.echo(f" {var:30s} {mark} {hint}")
1152
+ if required:
1153
+ issues.append(f"Set {var} (run `admina dev` or `./scripts/bootstrap-secrets.sh`)")
1154
+
1155
+ # ── Docker ───────────────────────────────────────────────
1156
+ click.echo("\n Infrastructure:")
1157
+ docker_ok = _check_docker()
1158
+ click.echo(
1159
+ f" Docker {ok_mark if docker_ok else warn_mark + ' not found (needed for full stack)'}"
1160
+ )
1161
+
1162
+ # ── docker compose stack ─────────────────────────────────
1163
+ if docker_ok and Path("docker-compose.yml").exists():
1164
+ try:
1165
+ ps = subprocess.run(
1166
+ ["docker", "compose", "ps", "--format", "{{.Name}}\t{{.Status}}"],
1167
+ capture_output=True,
1168
+ text=True,
1169
+ timeout=10,
1170
+ )
1171
+ if ps.returncode == 0 and ps.stdout.strip():
1172
+ lines = [ln for ln in ps.stdout.strip().split("\n") if ln.strip()]
1173
+ healthy = sum(1 for ln in lines if "healthy" in ln.lower())
1174
+ unhealthy = [ln for ln in lines if "unhealthy" in ln.lower()]
1175
+ running = len(lines)
1176
+ click.echo(
1177
+ f" docker compose {ok_mark} {running} services running, {healthy} healthy"
1178
+ )
1179
+ for ln in unhealthy:
1180
+ name = ln.split("\t", 1)[0]
1181
+ click.echo(f" {warn_mark} {name} is unhealthy")
1182
+ issues.append(
1183
+ f"Container {name} unhealthy — check `docker compose logs {name}`"
1184
+ )
1185
+ else:
1186
+ click.echo(" docker compose -- no stack running (run: admina dev)")
1187
+ except (subprocess.SubprocessError, FileNotFoundError):
1188
+ pass
1189
+
1190
+ # ── Dashboard reachability ───────────────────────────────
1191
+ if docker_ok:
1192
+ try:
1193
+ import urllib.request as _req
1194
+
1195
+ with _req.urlopen("http://localhost:3000/health", timeout=2) as r:
1196
+ if r.status == 200:
1197
+ click.echo(f" dashboard /health {ok_mark} http://localhost:3000")
1198
+ except (OSError, ValueError):
1199
+ click.echo(
1200
+ " dashboard /health -- not reachable (stack down or different port)"
1201
+ )
1202
+
1203
+ # ── Proxy reachability ───────────────────────────────────
1204
+ if docker_ok:
1205
+ try:
1206
+ import urllib.request as _req
1207
+
1208
+ with _req.urlopen("http://localhost:8080/health", timeout=2) as r:
1209
+ if r.status == 200:
1210
+ click.echo(f" proxy /health {ok_mark} http://localhost:8080")
1211
+ except (OSError, ValueError):
1212
+ click.echo(
1213
+ " proxy /health -- not reachable (stack down or different port)"
1214
+ )
1215
+
1216
+ # ── Summary ──────────────────────────────────────────────
1217
+ click.echo("\n" + "=" * 55)
1218
+ if not issues:
1219
+ click.echo(" All checks passed. Admina is ready.")
1220
+ else:
1221
+ click.echo(f" {len(issues)} issue(s) found:")
1222
+ for issue in issues:
1223
+ click.echo(f" - {issue}")
1224
+ click.echo("=" * 55)
1225
+ click.echo("")
1226
+
1227
+ if issues:
1228
+ sys.exit(1)
1229
+
1230
+
1231
+ # ── admina password commands ──────────────────────────────
1232
+
1233
+
1234
+ @app.group()
1235
+ def password() -> None:
1236
+ """Manage Admina platform credentials."""
1237
+
1238
+
1239
+ @password.command("show")
1240
+ def password_show() -> None:
1241
+ """Display current API key and password from the vault."""
1242
+ vault = SecretVault(Path.cwd())
1243
+ if not vault.is_initialized:
1244
+ click.echo(" No vault found. Run 'admina init' or 'admina dev' first.", err=True)
1245
+ raise SystemExit(1)
1246
+
1247
+ if sys.stdin.isatty():
1248
+ click.confirm(" This will display secrets in your terminal. Continue?", abort=True)
1249
+
1250
+ data = vault.export_env()
1251
+ click.echo()
1252
+ click.echo(f" API Key: {data.get('ADMINA_API_KEY', '(not set)')}")
1253
+ click.echo(f" Password: {data.get('ADMINA_DASHBOARD_PASSWORD', '(not set)')}")
1254
+ click.echo(" (shared across dashboard, Grafana, MinIO, ClickHouse)")
1255
+ click.echo()
1256
+
1257
+
1258
+ @password.command("reset")
1259
+ def password_reset() -> None:
1260
+ """Generate a new random password and API key."""
1261
+ vault = SecretVault(Path.cwd())
1262
+ if not vault.is_initialized:
1263
+ click.echo(" No vault found. Run 'admina init' or 'admina dev' first.", err=True)
1264
+ raise SystemExit(1)
1265
+
1266
+ generated = vault.bootstrap()
1267
+ vault.write_dotenv(Path.cwd() / ".env")
1268
+
1269
+ click.echo()
1270
+ click.echo(" Credentials regenerated:")
1271
+ click.echo(f" API Key: {generated['ADMINA_API_KEY']}")
1272
+ click.echo(f" Password: {generated['ADMINA_DASHBOARD_PASSWORD']}")
1273
+ click.echo()
1274
+ click.echo(" Restart services to apply: docker compose up --build -d")
1275
+ click.echo()
1276
+
1277
+
1278
+ @password.command("set")
1279
+ @click.option(
1280
+ "--password",
1281
+ "new_password",
1282
+ prompt=True,
1283
+ hide_input=True,
1284
+ confirmation_prompt=True,
1285
+ help="New password.",
1286
+ )
1287
+ def password_set(new_password: str) -> None:
1288
+ """Set a custom password for all web UIs."""
1289
+ ok, issues = validate_password(new_password)
1290
+ if not ok:
1291
+ click.echo("\n Password does not meet requirements:", err=True)
1292
+ for issue in issues:
1293
+ click.echo(f" - {issue}", err=True)
1294
+ click.echo()
1295
+ raise SystemExit(1)
1296
+
1297
+ vault = SecretVault(Path.cwd())
1298
+ if not vault.is_initialized:
1299
+ click.echo(" No vault found. Run 'admina init' or 'admina dev' first.", err=True)
1300
+ raise SystemExit(1)
1301
+
1302
+ vault.update_password(new_password)
1303
+ vault.write_dotenv(Path.cwd() / ".env")
1304
+
1305
+ click.echo("\n Password updated across all services.")
1306
+ click.echo(" Restart services to apply: docker compose up --build -d\n")
1307
+
1308
+
1309
+ @app.command()
1310
+ @click.option(
1311
+ "--output",
1312
+ "-o",
1313
+ type=click.Path(path_type=Path),
1314
+ default=Path("admina.yaml"),
1315
+ help="Where to write the resulting YAML (default: ./admina.yaml).",
1316
+ )
1317
+ @click.option(
1318
+ "--non-interactive",
1319
+ is_flag=True,
1320
+ help="Skip prompts and write a default-restrictive admina.yaml.",
1321
+ )
1322
+ def configure(output: Path, non_interactive: bool) -> None:
1323
+ """Interactive wizard to produce an admina.yaml.
1324
+
1325
+ Walks through the small set of choices that affect day-1 behaviour:
1326
+ governance mode (enforce / observe / dry-run), firewall categories
1327
+ to disable, loop-breaker thresholds, PII categories.
1328
+
1329
+ Defaults are restrictive: enforce mode, all firewall categories on,
1330
+ EU-aware PII set on. The wizard never recommends a "Pro" upgrade
1331
+ or a paid feature — the OSS edition is fully usable as-is.
1332
+ """
1333
+
1334
+ if output.exists():
1335
+ if not click.confirm(f" {output} already exists. Overwrite?", default=False):
1336
+ click.echo(" Aborted. No file written.", err=True)
1337
+ raise SystemExit(1)
1338
+
1339
+ click.echo("")
1340
+ click.echo("=" * 55)
1341
+ click.echo(" Admina Configuration Wizard")
1342
+ click.echo("=" * 55)
1343
+ click.echo("")
1344
+ click.echo(" Defaults are restrictive (enforce mode, all categories on).")
1345
+ click.echo(" Press Enter at any prompt to keep the default.")
1346
+ click.echo("")
1347
+
1348
+ # ── Governance mode ───────────────────────────────────────
1349
+ if non_interactive:
1350
+ mode = "enforce"
1351
+ else:
1352
+ click.echo(" Governance mode:")
1353
+ click.echo(" enforce → block flagged requests (production)")
1354
+ click.echo(" observe → never block, log 'would have blocked'")
1355
+ click.echo(" dry-run → like observe + tag responses")
1356
+ mode = click.prompt(
1357
+ " Mode",
1358
+ default="enforce",
1359
+ type=click.Choice(["enforce", "observe", "dry-run"]),
1360
+ show_choices=False,
1361
+ )
1362
+
1363
+ # ── Firewall categories ───────────────────────────────────
1364
+ builtin_cats = [
1365
+ "instruction_override",
1366
+ "role_hijack",
1367
+ "prompt_extraction",
1368
+ "jailbreak",
1369
+ "delimiter_injection",
1370
+ "data_exfiltration",
1371
+ "tool_abuse",
1372
+ "obfuscation",
1373
+ "multilang_evasion",
1374
+ ]
1375
+ disabled: list[str] = []
1376
+ if not non_interactive:
1377
+ click.echo("")
1378
+ click.echo(" Firewall categories (all enabled by default):")
1379
+ for cat in builtin_cats:
1380
+ click.echo(f" - {cat}")
1381
+ raw = click.prompt(
1382
+ " Categories to DISABLE (comma-separated, empty for none)",
1383
+ default="",
1384
+ show_default=False,
1385
+ )
1386
+ disabled = [c.strip() for c in raw.split(",") if c.strip() in builtin_cats]
1387
+ unknown = [c.strip() for c in raw.split(",") if c.strip() and c.strip() not in builtin_cats]
1388
+ if unknown:
1389
+ click.echo(f" (ignored unknown categories: {', '.join(unknown)})", err=True)
1390
+
1391
+ # ── Loop breaker thresholds ───────────────────────────────
1392
+ if non_interactive:
1393
+ loop_window, loop_thresh, loop_max = 10, 0.85, 3
1394
+ else:
1395
+ click.echo("")
1396
+ click.echo(" Loop breaker (anti-runaway agent):")
1397
+ loop_window = click.prompt(" Sliding window size", default=10, type=int)
1398
+ loop_thresh = click.prompt(" TF-IDF similarity threshold", default=0.85, type=float)
1399
+ loop_max = click.prompt(" Max consecutive similar messages", default=3, type=int)
1400
+
1401
+ # ── PII categories ────────────────────────────────────────
1402
+ pii_default = [
1403
+ "email",
1404
+ "phone",
1405
+ "credit_card",
1406
+ "ssn",
1407
+ "iban",
1408
+ "ip",
1409
+ "person",
1410
+ "org",
1411
+ "it_codice_fiscale",
1412
+ "es_dni",
1413
+ ]
1414
+ if non_interactive:
1415
+ pii_categories = pii_default
1416
+ else:
1417
+ click.echo("")
1418
+ click.echo(" PII categories to redact:")
1419
+ for cat in pii_default:
1420
+ click.echo(f" - {cat}")
1421
+ raw = click.prompt(
1422
+ " Categories to ADD or REMOVE (e.g. '+de_personalausweis,-org')",
1423
+ default="",
1424
+ show_default=False,
1425
+ )
1426
+ pii_categories = list(pii_default)
1427
+ for token in raw.split(","):
1428
+ token = token.strip()
1429
+ if not token:
1430
+ continue
1431
+ if token.startswith("-") and token[1:] in pii_categories:
1432
+ pii_categories.remove(token[1:])
1433
+ elif token.startswith("+") and token[1:] not in pii_categories:
1434
+ pii_categories.append(token[1:])
1435
+
1436
+ # ── Write YAML ────────────────────────────────────────────
1437
+ yaml_text = _render_admina_yaml(
1438
+ mode=mode,
1439
+ firewall_disabled=disabled,
1440
+ loop_window=loop_window,
1441
+ loop_thresh=loop_thresh,
1442
+ loop_max=loop_max,
1443
+ pii_categories=pii_categories,
1444
+ )
1445
+ output.write_text(yaml_text, encoding="utf-8")
1446
+
1447
+ click.echo("")
1448
+ click.echo(f" Wrote {output}")
1449
+ click.echo("")
1450
+ click.echo(" Next steps:")
1451
+ click.echo(" - Review the generated file")
1452
+ click.echo(" - Set ADMINA_API_KEY in .env (run 'admina dev' to bootstrap)")
1453
+ click.echo(" - Start the stack: admina dev")
1454
+ click.echo("")
1455
+ if mode != "enforce":
1456
+ click.echo(
1457
+ f" ⚠ Governance mode is '{mode}' — flagged requests will NOT "
1458
+ "be blocked. Switch to 'enforce' once you have tuned the policies."
1459
+ )
1460
+ click.echo("")
1461
+
1462
+
1463
+ def _render_admina_yaml(
1464
+ *,
1465
+ mode: str,
1466
+ firewall_disabled: list[str],
1467
+ loop_window: int,
1468
+ loop_thresh: float,
1469
+ loop_max: int,
1470
+ pii_categories: list[str],
1471
+ ) -> str:
1472
+ """Render an admina.yaml from wizard inputs. Pure function for testing."""
1473
+ disabled_list = (
1474
+ "\n ".join(f"- {c}" for c in firewall_disabled) if firewall_disabled else "[]"
1475
+ )
1476
+ pii_list = ", ".join(pii_categories)
1477
+ disabled_block = (
1478
+ f" disabled_categories:\n {disabled_list}"
1479
+ if firewall_disabled
1480
+ else " disabled_categories: []"
1481
+ )
1482
+ return f"""# Generated by `admina configure`.
1483
+ # Edit freely; this is the source of truth for the proxy at startup.
1484
+
1485
+ schema_version: 1
1486
+
1487
+ domains:
1488
+ data_sovereignty:
1489
+ enabled: true
1490
+ pii:
1491
+ enabled: true
1492
+ categories: [{pii_list}]
1493
+ ner_model: en_core_web_sm
1494
+
1495
+ agent_security:
1496
+ enabled: true
1497
+ firewall:
1498
+ enabled: true
1499
+ mode: {mode}
1500
+ {disabled_block}
1501
+ custom_patterns: []
1502
+ loop_breaker:
1503
+ enabled: true
1504
+ window_size: {loop_window}
1505
+ similarity_threshold: {loop_thresh}
1506
+ max_consecutive: {loop_max}
1507
+
1508
+ compliance:
1509
+ enabled: true
1510
+ eu_ai_act:
1511
+ enabled: true
1512
+
1513
+ dashboard:
1514
+ enabled: true
1515
+ port: 3000
1516
+
1517
+ auth_provider: apikey
1518
+ """
1519
+
1520
+
1521
+ if __name__ == "__main__":
1522
+ app()