Add controlled Athena platform operator

This commit is contained in:
Mikei386
2026-08-23 21:10:44 +02:00
parent 2f4bff550e
commit 94f3758795
26 changed files with 1030 additions and 752 deletions
+17 -2
View File
@@ -19,7 +19,8 @@ WireGuard-Isolation.
- Open WebUI als einzige normale Oberfläche
- SearXNG/Web-MCP ohne externen API-Schlüssel
- zentrale MCP-Werkzeugebene: getrennte Container für Athena-Plattformwissen,
Web, GitHub, HA, ARR, Unraid, Navidrome und Sandbox, gemeinsam nutzbar durch Open WebUI und andere
den kontrollierten Athena Operator, Web, GitHub, HA, ARR, Unraid, Navidrome
und Sandbox, gemeinsam nutzbar durch Open WebUI und andere
Clients
- KI-Dienste ausschließlich über den containerisierten WireGuard-Gateway erreichbar
- KI-Ausgangsverkehr über das Heimnetz, bei Tunnelausfall fail-closed
@@ -56,7 +57,7 @@ Neustart an; danach wird derselbe Befehl erneut ausgeführt.
| XTTS-v2 | nur Docker-intern, RTX 3060 | primäre mehrsprachige Sprachausgabe |
| TTS Gateway | nur Docker-intern | Annmarie Nele, Queue und Piper-Fallback |
| Piper | nur Docker-intern, CPU | ausfallsichere deutsche Ersatzstimme |
| MCP-Tool-Stack | nur Docker-intern | Web, GitHub, Home Assistant, ARR, Unraid und Navidrome |
| MCP-Tool-Stack | nur Docker-intern | Athena-Kontext, Athena Operator, Web, GitHub, Home Assistant, ARR, Unraid und Navidrome |
XTTS-v2, TTS-Gateway, Piper-Fallback und der FLUX.2-Klein-Hot-Swap sind
reproduzierbare Kerndienste; STT
@@ -78,6 +79,20 @@ keine Modell-Tokens und verraten dem Modell keine zusätzlichen Daten.
## Dokumentation
### API-Schnellreferenz
Für Zettelrobbe und andere OpenAI-kompatible Clients gilt im Heimnetz:
```text
Base URL: http://192.168.1.212:8081/v1
API-Key: Inhalt von /etc/mike-ai/router-api-key auf Athena
```
Den Schlüssel auf Athena ausschließlich lokal mit
`sudo cat /etc/mike-ai/router-api-key` anzeigen und direkt in den Secret-Store
des Clients kopieren. Er gehört niemals in Git, eine URL oder einen Chat. Die
entsprechende Open-WebUI-Adresse auf Port `8080` ist keine API-Basisadresse.
- [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md) – kurze Gesamtsicht
- [`docs/QWEN_OPERATOR_CONTEXT.md`](docs/QWEN_OPERATOR_CONTEXT.md) – ausführliches Kontextpaket für das lokale Operator-Modell
- [`docs/PLATFORM_CONTEXT_MCP.md`](docs/PLATFORM_CONTEXT_MCP.md) – profilunabhängiges Plattformwissen und kontrollierte Dokumentationspflege
+9
View File
@@ -0,0 +1,9 @@
ATHENA_OPERATOR_STACK=/opt/mike-ai/stack
ATHENA_OPERATOR_REPOSITORY=/data/mike-ai-operator/repository
ATHENA_OPERATOR_STATE=/data/mike-ai-operator/state
ATHENA_OPERATOR_MODELS=/data/models
ATHENA_OPERATOR_SOCKET=/run/mike-ai-operator/operator.sock
ATHENA_OPERATOR_GID=10003
ATHENA_OPERATOR_GIT_REMOTE=git@192.168.1.2:michael/AI-Profile-Router.git
ATHENA_OPERATOR_GIT_NAME=Athena Operator
ATHENA_OPERATOR_GIT_EMAIL=athena-operator@localhost
+9
View File
@@ -23,6 +23,15 @@ proposal and receiving explicit user approval. A local docs update is not
complete until Git commit/push and the refreshed recovery kit are separately
verified.
For implementation and operation of Athena itself, use the Athena Operator MCP.
It is the single controlled management interface for versioned source changes,
Docker deployment, model downloads and benchmarks, Git publication and recovery
creation. Read and inspect directly; for every mutation first request a bounded
preview, show that preview to the user, and execute only the exact returned ticket
after explicit confirmation in a later message. Never claim that a preview was
executed. The Operator is intentionally not an arbitrary root shell and cannot
change SSH, networking, WireGuard, boot, kernel, disks, reboot or shutdown.
Athena is physically remote and normally has no KVM or on-site recovery. Never
shut down, reboot, power off, alter SSH, lan0, firewall, routing, WireGuard,
kernel, NVIDIA drivers, initramfs, bootloader, filesystems, partitions, mounts,
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""Offline safety and workflow tests for the Athena Operator executor."""
from __future__ import annotations
import importlib.util
import json
import tempfile
import time
import unittest
from pathlib import Path
SOURCE = Path(__file__).parents[1] / "platform" / "operator" / "athena_operatord.py"
def load_module():
spec = importlib.util.spec_from_file_location("athena_operatord_tested", SOURCE)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
class OperatorTests(unittest.TestCase):
def setUp(self):
self.module = load_module()
self.temporary = tempfile.TemporaryDirectory()
root = Path(self.temporary.name)
self.repo, self.stack = root / "repository", root / "stack"
self.models, self.state = root / "models", root / "state"
for path in (self.repo, self.stack, self.models, self.state):
path.mkdir()
(self.repo / ".git").mkdir()
(self.repo / "docs").mkdir()
(self.stack / "docs").mkdir()
for base in (self.repo, self.stack):
(base / "docs" / "test.md").write_text("before\n", encoding="utf-8")
self.module.REPOSITORY = self.repo.resolve()
self.module.STACK = self.stack.resolve()
self.module.MODELS = self.models.resolve()
self.module.STATE = self.state.resolve()
self.module.audit = lambda *args, **kwargs: None
self.module.run = lambda argv, **kwargs: {"argv": argv, "exit_code": 0, "output": "ok"}
def tearDown(self):
self.temporary.cleanup()
def prepare_file(self):
before = self.module.sha((self.repo / "docs" / "test.md").read_bytes())
return self.module.prepare({
"operation": "file_update",
"payload": {"files": [{"path": "docs/test.md", "content": "after\n", "expected_sha256": before}]},
})
def test_preview_changes_nothing(self):
proposal = self.prepare_file()
self.assertEqual((self.repo / "docs" / "test.md").read_text(), "before\n")
self.assertIn("-before", proposal["preview"])
self.assertIn("+after", proposal["preview"])
def test_exact_later_confirmation_updates_repo_and_deploy_tree(self):
proposal = self.prepare_file()
result = self.module.execute({"ticket": proposal["ticket"], "confirmation": proposal["required_confirmation"]})
self.assertEqual((self.repo / "docs" / "test.md").read_text(), "after\n")
self.assertEqual((self.stack / "docs" / "test.md").read_text(), "after\n")
self.assertEqual(result["operation"], "file_update")
with self.assertRaises(ValueError):
self.module.execute({"ticket": proposal["ticket"], "confirmation": proposal["required_confirmation"]})
def test_wrong_confirmation_and_expired_ticket_are_rejected(self):
proposal = self.prepare_file()
with self.assertRaises(PermissionError):
self.module.execute({"ticket": proposal["ticket"], "confirmation": "yes"})
path = self.state / "pending" / f"{proposal['ticket']}.json"
record = json.loads(path.read_text())
record["expires"] = int(time.time()) - 1
self.module.json_write(path, record)
with self.assertRaises(PermissionError):
self.module.execute({"ticket": proposal["ticket"], "confirmation": proposal["required_confirmation"]})
def test_source_drift_blocks_apply(self):
proposal = self.prepare_file()
(self.repo / "docs" / "test.md").write_text("drift\n")
with self.assertRaises(RuntimeError):
self.module.execute({"ticket": proposal["ticket"], "confirmation": proposal["required_confirmation"]})
def test_remote_access_and_power_operations_do_not_exist(self):
self.assertNotIn("shell", self.module.ALLOWED_OPERATIONS)
for operation in ("shutdown", "reboot", "ssh", "network", "command"):
with self.assertRaises(ValueError):
self.module.normalise_operation(operation, {})
def test_wireguard_gateway_cannot_be_stopped(self):
with self.assertRaises(PermissionError):
self.module.normalise_operation("container_action", {"action": "stop", "containers": ["mike-ai-wireguard-gateway"]})
def test_only_huggingface_model_downloads_are_accepted(self):
with self.assertRaises(PermissionError):
self.module.normalise_operation("model_download", {"url": "https://evil.invalid/model.gguf", "destination": "model.gguf"})
payload, _ = self.module.normalise_operation("model_download", {"url": "https://huggingface.co/org/repo/resolve/main/model.gguf", "destination": "qwen/model.gguf"})
self.assertEqual(payload["destination"], "qwen/model.gguf")
def test_openwebui_sync_is_structured_and_requires_a_ticket(self):
payload, preview = self.module.normalise_operation("openwebui_sync", {})
self.assertEqual(payload, {})
self.assertIn("Synchronise", preview)
def test_git_publish_preview_is_bound_to_reviewed_worktree(self):
calls = []
def fake_run(argv, **kwargs):
calls.append(argv)
if argv[:3] == ["git", "status", "--short"]:
return {"argv": argv, "exit_code": 0, "output": " M compose.yaml"}
if argv[:3] == ["git", "diff", "--stat"]:
return {"argv": argv, "exit_code": 0, "output": " compose.yaml | 2 +-"}
return {"argv": argv, "exit_code": 0, "output": "ok"}
self.module.run = fake_run
payload, preview = self.module.normalise_operation("git_publish", {"message": "Update Athena platform"})
self.assertEqual(payload["reviewed_status"], "M compose.yaml")
self.assertIn("compose.yaml | 2 +-", preview)
def test_git_publish_rejects_empty_repository(self):
self.module.run = lambda argv, **kwargs: {"argv": argv, "exit_code": 0, "output": ""}
with self.assertRaises(RuntimeError):
self.module.normalise_operation("git_publish", {"message": "Update Athena platform"})
def test_path_traversal_and_protected_paths_are_rejected(self):
for path in ("../etc/passwd", ".git/config", "/etc/passwd", "secrets/key"):
with self.subTest(path=path), self.assertRaises((ValueError, PermissionError)):
self.module.safe_relative(path)
if __name__ == "__main__":
unittest.main(verbosity=2)
-99
View File
@@ -1,99 +0,0 @@
#!/usr/bin/env python3
"""Offline abuse and capability tests for the bounded Athena terminal MCP."""
from __future__ import annotations
import importlib.util
import tempfile
import unittest
from pathlib import Path
SOURCE = Path(__file__).parents[1] / "platform" / "mcp" / "athena_terminal_mcp.py"
def load_module():
spec = importlib.util.spec_from_file_location("athena_terminal_mcp_tested", SOURCE)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
class AthenaTerminalTests(unittest.TestCase):
def setUp(self):
self.module = load_module()
self.temporary = tempfile.TemporaryDirectory()
root = Path(self.temporary.name)
self.workspace = root / "workspace"
self.runtime = root / "runtime"
self.workspace.mkdir()
self.runtime.mkdir()
(self.workspace / "README.md").write_text("Athena evidence\n", encoding="utf-8")
(self.workspace / "valid.json").write_text('{"ok":true}\n', encoding="utf-8")
self.module.WORKSPACE_ROOT = self.workspace.resolve()
self.module.RUNTIME_ROOT = self.runtime.resolve()
self.module.ALLOWED_ROOTS = (
self.module.WORKSPACE_ROOT,
self.module.RUNTIME_ROOT,
)
def tearDown(self):
self.temporary.cleanup()
def test_allowed_read_works(self):
result = self.module.run_command(
{"program": "cat", "arguments": ["README.md"], "working_directory": "workspace"}
)
self.assertEqual(result["exit_code"], 0)
self.assertEqual(result["output"], "Athena evidence\n")
self.assertTrue(result["read_only"])
def test_power_remote_shell_and_admin_programs_are_blocked(self):
for program in (
"shutdown", "reboot", "poweroff", "ssh", "scp", "bash",
"python3", "docker", "systemctl", "curl", "wget", "sudo",
):
with self.subTest(program=program), self.assertRaises(PermissionError):
self.module.validate_arguments(program, [], self.workspace)
def test_shell_syntax_is_blocked(self):
for value in ("$(id)", "${HOME}", "`id`", "a|b", "x;y", ">file", "a&&b"):
with self.subTest(value=value), self.assertRaises(ValueError):
self.module.clean_scalar(value)
def test_path_escape_and_symlink_escape_are_blocked(self):
with self.assertRaises(PermissionError):
self.module.safe_path("/etc/passwd", self.workspace)
(self.workspace / "escape").symlink_to("/etc/passwd")
with self.assertRaises(PermissionError):
self.module.safe_path("escape", self.workspace)
def test_secret_like_path_is_blocked(self):
secret = self.workspace / "credentials"
secret.write_text("nope", encoding="utf-8")
with self.assertRaises(PermissionError):
self.module.safe_path("credentials", self.workspace)
def test_ripgrep_preprocessor_and_find_are_not_available(self):
with self.assertRaises(ValueError):
self.module.validate_arguments("rg", ["--pre", "sh", "x"], self.workspace)
with self.assertRaises(PermissionError):
self.module.validate_arguments("find", ["."], self.workspace)
with self.assertRaises(ValueError):
self.module.validate_arguments("grep", ["-R", "Athena", "."], self.workspace)
def test_validation_parses_without_execution(self):
result = self.module.validate_source({"path": "valid.json", "kind": "auto"})
self.assertTrue(result["valid"])
self.assertFalse(result["executed"])
self.assertFalse(result["modified"])
def test_policy_states_missing_capabilities(self):
unavailable = " ".join(self.module.policy()["unavailable"])
for word in ("SSH", "shutdown", "reboot", "Docker", "network"):
self.assertIn(word, unavailable)
if __name__ == "__main__":
unittest.main(verbosity=2)
+4 -4
View File
@@ -170,12 +170,12 @@ class AutoToolSelectorTests(unittest.IsolatedAsyncioTestCase):
)
self.assertEqual(result["tool_ids"], ["server:mcp:athena-platform"])
async def test_athena_terminal_is_selected_only_for_explicit_terminal_work(self):
async def test_athena_operator_is_selected_for_platform_work(self):
result = await self._select(
"Zeige mir im Athena-Terminal mit ls die Dateien des KI-Hosts."
"Baue und deploye auf Athena einen neuen MCP-Container."
)
self.assertEqual(
result["tool_ids"], ["server:mcp:athena-terminal-local"]
result["tool_ids"], ["server:mcp:athena-operator-local"]
)
async def test_mcp_build_from_github_gets_source_and_platform_context(self):
@@ -184,7 +184,7 @@ class AutoToolSelectorTests(unittest.IsolatedAsyncioTestCase):
)
self.assertEqual(
result["tool_ids"],
["server:mcp:github-local", "server:mcp:athena-platform"],
["server:mcp:github-local", "server:mcp:athena-operator-local"],
)
async def test_github_and_explicit_web_are_bounded_to_two(self):
+1 -1
View File
@@ -13,7 +13,7 @@
| Navidrome-MCP | Blakeem/Navidrome-MCP 2.2.0, Image per OCI-Digest | eigener optionaler Container ohne mpv | optional |
| GitHub-MCP | offizieller `github/github-mcp-server` 1.10.1, vier read-only Werkzeuge | eigener optionaler Container hinter Streamable-HTTP-Brücke | optional |
| Platform Context MCP | Athena-/MikeAI-Wissen, begrenzter Laufzeitsnapshot und kontrollierte Dokumentationspflege | eigener Container ohne Docker-Socket, Shell, Egress oder Secrets | Kern |
| Athena Terminal MCP | gezielte Lesebefehle und Syntaxprüfung in Stack/Snapshot | eigener read-only Container ohne Host-Shell, Docker, SSH, Egress, Secrets oder Power-Befehle | Kern |
| Athena Operator MCP | Entwicklung und vollständiger Betrieb der KI-Plattform mit gebundenen Freigaben | unprivilegierte MCP-Fassade plus rootseitiger strukturierter Executor; keine freie Shell | Kern |
| Operator-Kontext | `docs/QWEN_OPERATOR_CONTEXT.md` plus `config/operator-system-prompt.txt` | versionierte Selbstbeschreibung und Sicherheitsregeln für Qwen | Kern |
| Unraid-MCP | lokales `runraid`-Binary | eigener optionaler Container | optional |
| Whisper | ggml-org/whisper.cpp | Service im Router-Deploy | optional |
+8 -7
View File
@@ -158,7 +158,7 @@ Der isolierte Eignungs- und Ausfalltest ist in
Aktuell existieren funktionale Adapter für:
- Athena-Plattformwissen, Laufzeitsnapshot und kontrollierte Docs-Pflege
- Athena Terminal: eng begrenzte Lesebefehle und Syntaxprüfung in Stack/Snapshot
- Athena Operator: Entwicklung, Docker/MCP/Modelle, Tests, Git und Recovery
- Websuche
- Home Assistant
- Sonarr/Radarr
@@ -187,12 +187,13 @@ Ein root-eigener Minutentimer erzeugt nur einen begrenzten Laufzeitsnapshot.
Der Schreibpfad ist auf `docs/*.md`, Vorschau, ausdrückliche Freigabe, atomare
Sicherung und sichtbare Git-/Recovery-Nacharbeit begrenzt.
Der ergänzende Athena Terminal MCP ist kein Host-Terminal. Er sieht nur den
read-only Stack und Laufzeitsnapshot und besitzt eine Positivliste weniger
Lesebefehle. SSH/SCP, Docker, Netzwerkclients, Interpreter, `systemctl`,
Prozesssignale, Reboot, Shutdown, Secrets und Schreibzugriffe sind nicht
verfügbar. Der Auto Tool Selector lädt ihn nur bei ausdrücklich auf Athena
bezogener Terminal-/Shell-Arbeit.
Der Athena Operator MCP ersetzt die frühere begrenzte Terminal-Fassade. Er ist
die zusammenhängende Bedienebene, mit der Qwen die KI-Plattform selbst
weiterentwickeln und betreiben kann. Quellenlesen, Dateiänderungen, Tests,
Compose-Deployments, Containeraktionen, Modell-Downloads, Benchmarks,
Git-Publishing und Recovery sind strukturiert verfügbar. Zustandsänderungen
benötigen immer Vorschau und ein content-gebundenes Approval-Ticket. Ein freies
Root-Terminal sowie Remotezugang, Netzwerk/SSH/Boot/Power bleiben getrennt.
Der frühere allgemeine Shell-MCP und doppelte, schreibende Werkzeuge gehören
nicht zum Sicherheitsziel und werden nicht ungeprüft wiederhergestellt.
+6 -5
View File
@@ -57,7 +57,7 @@ laufen, sondern alle fachlichen Funktionen geprüft wurden.
- [ ] Filter und Quick Actions sind allen fünf Presets zugeordnet
- [ ] Auto Tool Selector wählt bei harmlosen Testfragen Web, GitHub, Home
Assistant, ARR, Navidrome, Unraid read-only, Athena-Plattformwissen und
das begrenzte Athena-Terminal korrekt
den Athena Operator korrekt
- [ ] normale Unterhaltung erhält kein MCP; MUA wird niemals automatisch gewählt
- [ ] SearXNG und TinySearch gesund
@@ -79,8 +79,9 @@ laufen, sondern alle fachlichen Funktionen geprüft wurden.
System-Prompt entsprechen dem wiederhergestellten Stand
- [ ] Platform-Context-Snapshot aktuell; offene Vorschläge und angewandte
Dokumentationsänderungen mit `athena_get_maintenance_status` geprüft
- [ ] Athena-Terminal gesund; erlaubter Leseaufruf erfolgreich; SSH, Docker,
Reboot, Shutdown und Pfadausbruch in Negativtests verweigert
- [ ] Athena Operator und rootseitiger Executor gesund; Lesen und Preview
erfolgreich; falsches/abgelaufenes Ticket, Pfadausbruch, WireGuard-Stopp,
freie Befehle, SSH, Reboot und Shutdown in Negativtests verweigert
- [ ] lokales Dokumentations-Overlay ist auch im privaten Git enthalten und
der Recovery-Koffer wurde danach neu erzeugt
@@ -110,8 +111,8 @@ laufen, sondern alle fachlichen Funktionen geprüft wurden.
- [ ] Router nur aus erlaubtem Netz erreichbar
- [ ] Dienste laufen mit minimalen Rechten
- [ ] Environment-Dateien Modus 0600
- [ ] kein allgemeiner Shell-MCP im Standardprofil; Athena-Terminal besitzt nur
die dokumentierte Positivliste und zwei read-only Mounts
- [ ] kein allgemeiner Shell-MCP im Standardprofil; Athena Operator besitzt nur
strukturierte, ticketgebundene Plattformoperationen
- [ ] Schreibaktionen verlangen Vorschau und Approval Ticket
- [ ] Secret-Restore wurde ohne Klartextausgabe durchgeführt
- [ ] verschlüsseltes Recovery-Bundle liegt außerhalb von Athena
+20 -1
View File
@@ -143,14 +143,33 @@ zentralen Secret-, Prompt-Injection- und Tool-Output-Filter bleiben aktiv.
Clients verbinden sich mit:
```text
http://<FRITZ-VPN-IP>:8081/v1
http://192.168.1.212:8081/v1
```
Dies ist die OpenAI-kompatible Router-API für Zettelrobbe, Hermes und andere
Clients im Heimnetz. `http://192.168.1.212:8080` ist ausschließlich
Open WebUI und darf nicht als API-Basisadresse eingetragen werden.
Als API-Key verwenden sie den Inhalt von `/etc/mike-ai/router-api-key` über
Bearer-Authentifizierung. Der Key gehört in den Secret-Store des Clients,
nicht in Chat, Repository oder URL. Eine Rotation erfolgt atomar durch
Ersetzen der Datei und Neustart des Routerdienstes.
Nur lokal auf Athena anzeigen und direkt in den Zielclient kopieren:
```bash
sudo cat /etc/mike-ai/router-api-key
```
Ein einfacher Verbindungstest ohne Ausgabe des Schlüssels:
```bash
ROUTER_API_KEY=$(sudo cat /etc/mike-ai/router-api-key)
curl -fsS http://192.168.1.212:8081/v1/models \
-H "Authorization: Bearer $ROUTER_API_KEY"
unset ROUTER_API_KEY
```
Sie sollen nicht direkt Port 8080 verwenden, weil sie sonst Profilumschaltung,
Vision, Bildgenerierung, STT und TTS umgehen.
+7 -5
View File
@@ -96,11 +96,13 @@ Der offizielle GitHub-MCP bietet nur vier Werkzeuge:
Andere GitHub-Werkzeuge sowie Schreibzugriffe sind serverseitig deaktiviert.
Für gezielte Beweissuche im Athena-Stack existiert zusätzlich eine begrenzte
Terminal-Fassade. Sie ist kein allgemeiner Shell-MCP: Nur fest erlaubte
Lesebefehle gegen zwei read-only Mounts sind möglich. Docker, SSH, Netzwerk,
Secrets, Service-/Power-Steuerung und jede Form von Schreiben bleiben außerhalb
dieser Vertrauensgrenze.
Für Entwicklung und Betrieb der KI-Plattform existiert ein zentraler Athena
Operator MCP. Eine unprivilegierte MCP-Fassade spricht ausschließlich über
einen Unix-Socket mit einem rootseitigen strukturierten Executor. Dadurch kann
Qwen MCPs, Docker-Dienste, Modelle, Profile, OpenWebUI, Tests, Git und Recovery
selbst pflegen, erhält aber keinen freien Root-Befehl. Jede Mutation wird als
gebundene Vorschau vorbereitet und erst nach ausdrücklicher späterer Freigabe
ausgeführt. Netzwerk-, SSH-, Boot- und Powerzugriffe sind nicht Teil davon.
## Verbindliche Quellen
+11 -7
View File
@@ -226,7 +226,7 @@ Netzzugriff. Kein MCP-Port wird am Host veröffentlicht.
| Bereich | Aufgabe | Rechte |
|---|---|---|
| Athena-Plattform | Architektur, Quellen, Laufzeitsnapshot, Dokumentationspflege | Lesen; Markdown nur Preview/Approval |
| Athena Terminal | gezieltes Lesen und Syntaxprüfung in Stack/Snapshot | strikt read-only; kein Host-Zugriff |
| Athena Operator | vollständige Entwicklung und Betrieb der KI-Plattform | Lesen direkt; Änderungen nur Preview/Ticket/Approval |
| Web | aktuelle öffentliche Recherche über SearXNG/TinySearch | read-only |
| GitHub | Repositorysuche, Baum, Dateiinhalt, Code-Suche | strikt read-only, vier Tools |
| Home Assistant | Zustände, Historie, Diagnose, begrenzte YAML-Abläufe | Lesen; Schreiben nur Preview/Approval |
@@ -235,12 +235,16 @@ Netzzugriff. Kein MCP-Port wird am Host veröffentlicht.
| Unraid | Host-, Docker-, Array-, Netzwerk- und Logdiagnose | read-only Standard |
| MUA/Admin | eng definierte Unraid-Verwaltung | bewusst aktivieren |
`Athena Terminal` ist trotz seines Namens keine allgemeine Shell. Das Werkzeug
stellt ausschließlich `ls`, `cat`, `head`, `tail`, `wc`, `stat`, `file`,
`sha256sum`, `grep`, `rg`, `sed`, `du`, `df` und reine Syntaxprüfung bereit.
Es kann weder Dateien ändern noch Docker, SSH, Netzwerk, Dienste, Prozesse,
Reboot oder Shutdown steuern. Fordere keine Umgehung dieser Grenze an und
behaupte nach einem Leseaufruf niemals, eine Änderung deployed zu haben.
`Athena Operator` ist die zentrale Arbeitsumgebung für Änderungen an Athena.
Nutze ihn zum Lesen der tatsächlichen Quellen, Erstellen und Anwenden von
Dateiänderungen, Testen, Deployen von Compose-Diensten und MCPs, Verwalten der
MikeAI-Container, Laden und Prüfen von Modellen, Starten versionierter
Benchmarks, Git-Publishing und Recovery. Änderungen erfolgen stets in zwei
getrennten Phasen: vollständige Vorschau erzeugen, dem Benutzer zeigen und
stoppen; erst nach dessen späterer exakter Ticketbestätigung ausführen. Ein
freier Shellbefehl, SSH, Netzwerk-/WireGuard-/Firewall-Umbau, Boot/Kernel/
Treiber/Partitionen sowie Reboot und Shutdown sind nicht Bestandteil des
Operators und dürfen nicht umgangen werden.
Der offizielle GitHub-MCP `github/github-mcp-server` 1.10.1 läuft hinter einer
reinen stdio-zu-Streamable-HTTP-Brücke. Aktiv sind ausschließlich:
+8 -5
View File
@@ -54,11 +54,14 @@ Sandbox. Er ist klein, testbar und nicht von Clients direkt erreichbar.
| Administration | Vorschau, Approval-Ticket, Verifikation |
Allgemeine Shell, beliebiges SSH/SCP, freies `curl`, Docker-Administration und
freie Dateisystemsuche gehören nicht ins Standardprofil. Der Athena Terminal
MCP ist davon ausdrücklich keine Ausnahme: Er sieht nur zwei read-only Mounts,
besitzt weder Docker-Socket noch Egress oder Host-PID-Namensraum und akzeptiert
nur eine kleine Positivliste direkter Lesebefehle. Reboot, Shutdown, SSH,
Interpreter, Service-Steuerung und sämtliche Schreibwege sind ausgeschlossen.
freie Dateisystemsuche gehören nicht ins Standardprofil. Für die Athena-
Plattform existiert genau ein Operator-MCP. Seine unprivilegierte Fassade sieht
nur einen lokalen Unix-Socket; ein rootseitiger Executor besitzt die für
Repository, Docker, Modelle, Git und Recovery notwendigen Rechte. Er bietet
keinen beliebigen Befehl an, sondern strukturierte Operationen mit Vorschau,
Inhaltsbindung, Ablaufzeit und späterer exakter Freigabe. SSH-, Netzwerk-,
WireGuard-, Firewall-, Boot-, Kernel-, Treiber-, Partitions-, Reboot- und
Shutdown-Änderungen liegen außerhalb seiner API.
## Schreibaktionen
+1 -1
View File
@@ -64,7 +64,7 @@ else
fi
for optional in mike-ai-mcp-web mike-ai-mcp-homeassistant mike-ai-mcp-arr \
mike-ai-mcp-github mike-ai-mcp-platform-context mike-ai-mcp-athena-terminal \
mike-ai-mcp-github mike-ai-mcp-platform-context mike-ai-mcp-athena-operator \
mike-ai-mcp-unraid-official; do
if container_healthy "$optional"; then
pass "$optional aktiv"
+13
View File
@@ -0,0 +1,13 @@
FROM python:3.13-slim@sha256:ffb752e139c0a19692a43af8d8523b274222dd68eebad5d583b45c2201c6e30a
ARG MCP_PROXY_VERSION=0.12.0
ARG MCP_VERSION=1.29.0
RUN pip install --no-cache-dir "mcp==${MCP_VERSION}" "mcp-proxy==${MCP_PROXY_VERSION}" \
&& useradd --system --uid 10003 --create-home --home-dir /app operator
COPY athena_operator_mcp.py /app/athena_operator_mcp.py
RUN chown -R 10003:10003 /app
USER 10003:10003
WORKDIR /app
EXPOSE 8000
ENTRYPOINT ["mcp-proxy", "--host", "0.0.0.0", "--port", "8000", "--stateless", "--"]
CMD ["python", "/app/athena_operator_mcp.py"]
-20
View File
@@ -1,20 +0,0 @@
FROM python:3.13-slim@sha256:ffb752e139c0a19692a43af8d8523b274222dd68eebad5d583b45c2201c6e30a
ARG MCP_PROXY_VERSION=0.12.0
ARG MCP_VERSION=1.29.0
ARG PYYAML_VERSION=6.0.3
RUN apt-get update \
&& apt-get install -y --no-install-recommends bash file ripgrep \
&& rm -rf /var/lib/apt/lists/* \
&& pip install --no-cache-dir \
"mcp==${MCP_VERSION}" "mcp-proxy==${MCP_PROXY_VERSION}" "PyYAML==${PYYAML_VERSION}" \
&& useradd --system --uid 10002 --create-home --home-dir /app terminal
COPY athena_terminal_mcp.py /app/athena_terminal_mcp.py
RUN chown -R 10002:10002 /app
USER 10002:10002
WORKDIR /app
EXPOSE 8000
ENTRYPOINT ["mcp-proxy", "--host", "0.0.0.0", "--port", "8000", "--stateless", "--"]
CMD ["python", "/app/athena_terminal_mcp.py"]
+13 -11
View File
@@ -11,7 +11,7 @@ Prompts heraus, verhindert den früher beobachteten Kontextverbrauch von über
| Container | Endpunkt im Netz `mike-ai-tools` | Zweck | Standard |
|---|---|---|---|
| `mcp-platform-context` | `http://mike-ai-mcp-platform-context:8000/mcp` | Athena-Wissen, begrenzter Snapshot und kontrollierte Docs-Pflege | an |
| `mcp-athena-terminal` | `http://mike-ai-mcp-athena-terminal:8000/mcp` | fest begrenzte Lesebefehle und Syntaxprüfung für Athena-Quellen | an |
| `mcp-athena-operator` | `http://mike-ai-mcp-athena-operator:8000/mcp` | vollständiger Betrieb der Athena-KI-Plattform über Vorschau/Freigabe | an |
| `mcp-web` | `http://mike-ai-mcp-web:8000/mcp` | kompakte Websuche und Quellenvergleich | an |
| `mcp-homeassistant` | `http://mike-ai-mcp-homeassistant:8000/mcp` | Relay zum nativen HA-MCP; Token bleibt serverseitig | Profil `homeassistant` |
| `mcp-arr` | `http://mike-ai-mcp-arr:8000/mcp` | Sonarr/Radarr/Prowlarr mit serverseitiger Policy | Profil `arr` |
@@ -33,14 +33,16 @@ Host-Snapshot. Dokumentationspflege ist auf `docs/*.md` und einen zweistufigen
Preview/Approval-Ablauf begrenzt. Vollständige Beschreibung:
[`docs/PLATFORM_CONTEXT_MCP.md`](../../docs/PLATFORM_CONTEXT_MCP.md).
Der Athena Terminal MCP ist ebenfalls **keine allgemeine Shell**. Er führt nur
eine kurze serverseitige Positivliste von Lesewerkzeugen mit direkten
Argumenten aus (`ls`, `cat`, `head`, `tail`, `wc`, `stat`, `file`,
`sha256sum`, `grep`, `rg`, `sed`, `du`, `df`). Sichtbar sind ausschließlich
der read-only eingebundene versionierte Stack und der begrenzte
Laufzeitsnapshot. Shell-Syntax, Interpreter, Netzwerkprogramme, SSH/SCP,
Docker, `systemctl`, Prozesssignale, Reboot und Shutdown sind nicht vorhanden
beziehungsweise werden bereits vor der Ausführung abgewiesen.
Der Athena Operator MCP ist die einzige Bedienebene für Arbeiten an der lokalen
KI-Plattform. Qwen kann damit Quellen lesen, Änderungen vorbereiten, MCPs und
Docker-Dienste bauen/deployen, Modelle laden, Benchmarks starten, Profile und
OpenWebUI pflegen, Git veröffentlichen und Recovery erzeugen. Die unprivilegierte
MCP-Fassade sieht dabei nur einen lokalen Unix-Socket. Docker-Socket,
Repository, Modellverzeichnis, Git-Zugang und Root-Rechte verbleiben im
rootseitigen Executor. Jede Änderung benötigt eine vollständige Vorschau, ein
inhaltlich gebundenes, ablaufendes Ticket und eine spätere exakte Bestätigung.
Eine freie Shell sowie SSH-, Netzwerk-, Boot-, Kernel-, Treiber-, Partitions-,
Reboot- und Shutdown-Aktionen werden nicht angeboten.
TinySearch bleibt als Ganzes read-only. Nur das flüchtige tmpfs-Verzeichnis
`/home/tinysearch/.crawl4ai` ist beschreibbar, weil Crawl4AI dort seinen
@@ -77,7 +79,7 @@ passenden Server wählen:
| Serien, Filme, fehlende Episoden und Indexer-Releases | Sonarr und Radarr | Web |
| Persönliche Musikbibliothek, Titel, Alben, Künstler und Playlists | Navidrome | Web, ARR |
| Lesende NAS-, Docker-, Array-, Netzwerk- und Logdiagnose | Unraid (Systemdiagnose) | MUA |
| Athena-Quelldateien gezielt lesen oder Syntax prüfen | Athena Terminal | Platform Context für Architekturwissen |
| Athena-KI-Plattform entwickeln, testen, deployen, Modelle/Git/Recovery pflegen | Athena Operator | Platform Context für reine Architekturauskunft |
| Ausdrücklich benötigte MUA-Verwaltungsaktion | MUA | Unraid-Diagnose nicht parallel |
Ein leeres Ergebnis ist kein Grund, dieselbe Frage über mehrere unpassende
@@ -95,7 +97,7 @@ oder ein anderes Werkzeug benötigt wird.
`no-new-privileges`.
- Der SSH-basierte Unraid-Container ist nicht Teil des Standardstarts.
- Ein allgemeiner Host-Shell-MCP wird weiterhin bewusst nicht angeboten. Der
Athena Terminal MCP ist eine eng begrenzte Lesefassade, kein Host-Terminal.
Athena Operator besitzt strukturierte Plattformaktionen statt freier Befehle.
## Start
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""Single MCP facade for end-to-end Athena platform operation."""
from __future__ import annotations
import json
import os
import socket
import sys
from typing import Any
VERSION = "1.0.0"
SOCKET_PATH = os.environ.get("ATHENA_OPERATOR_SOCKET", "/operator/operator.sock")
if hasattr(sys.stdin, "reconfigure"):
sys.stdin.reconfigure(encoding="utf-8", errors="replace")
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
TOOLS = [
{
"name": "athena_operator_inspect",
"description": (
"USE FIRST for Athena administration. Inspect live platform state without changing it. "
"Subjects: overview, git_status, containers, models, jobs. This is the authoritative "
"starting point before MCP, Docker, model, profile, benchmark or recovery work."
),
"inputSchema": {
"type": "object",
"properties": {"subject": {"type": "string", "enum": ["overview", "git_status", "containers", "models", "jobs"], "default": "overview"}},
"additionalProperties": False,
},
},
{
"name": "athena_operator_read_source",
"description": (
"Read a versioned Athena repository file with SHA-256 and bounded line range. Use this "
"instead of guessing current Compose, MCP, router, model, OpenWebUI or recovery code."
),
"inputSchema": {
"type": "object",
"properties": {
"path": {"type": "string", "pattern": "^[A-Za-z0-9_.+/-]{1,240}$"},
"start_line": {"type": "integer", "minimum": 1, "maximum": 1000000, "default": 1},
"line_count": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 300},
},
"required": ["path"], "additionalProperties": False,
},
},
{
"name": "athena_operator_search_source",
"description": "Search the complete versioned Athena repository for exact text before designing or modifying a component.",
"inputSchema": {"type": "object", "properties": {"query": {"type": "string", "minLength": 1, "maxLength": 200}}, "required": ["query"], "additionalProperties": False},
},
{
"name": "athena_operator_prepare",
"description": (
"PREPARE a state-changing Athena operation. This never changes state. It returns a "
"content-bound ticket, exact preview and confirmation phrase. Supported operations: "
"file_update (write repository and deployed stack files), run_checks, compose_deploy, "
"container_action, openwebui_sync, git_publish, model_download, benchmark, recovery. Show the complete "
"preview to the user and stop. Never execute in the same autonomous tool sequence. "
"file_update payload: {files:[{path,content,expected_sha256?}]}; run_checks: "
"{checks:[operator-tests,openwebui-filter-tests,platform-verify,compose-main,compose-mcp]}; "
"compose_deploy: {compose_file,services,build}; container_action: {action,containers}; "
"openwebui_sync: {}; "
"git_publish: {message}; model_download: {url,destination,sha256?}; benchmark: "
"{script,arguments}; recovery: {label}."
),
"inputSchema": {
"type": "object",
"properties": {
"operation": {"type": "string", "enum": ["file_update", "run_checks", "compose_deploy", "container_action", "openwebui_sync", "git_publish", "model_download", "benchmark", "recovery"]},
"payload": {"type": "object"},
},
"required": ["operation", "payload"], "additionalProperties": False,
},
},
{
"name": "athena_operator_execute",
"description": (
"EXECUTE exactly one previously prepared Athena operation. Call only after the user "
"approved the exact preview in a later message and supplied the exact confirmation. "
"Tickets expire, are content-bound and single-use. Long downloads, benchmarks and "
"recovery work return a job id. This tool cannot alter SSH, networking, WireGuard, "
"firewall, boot, kernel, drivers, partitions, mounts, shutdown or reboot."
),
"inputSchema": {
"type": "object",
"properties": {
"ticket": {"type": "string", "pattern": "^[0-9a-f]{32}$"},
"confirmation": {"type": "string", "pattern": "^EXECUTE [0-9a-f]{32}$"},
},
"required": ["ticket", "confirmation"], "additionalProperties": False,
},
},
{
"name": "athena_operator_job",
"description": "Poll one asynchronous model download, benchmark or recovery job. Do not start duplicate jobs while it is running.",
"inputSchema": {"type": "object", "properties": {"job_id": {"type": "string", "pattern": "^[0-9a-f]{32}$"}}, "required": ["job_id"], "additionalProperties": False},
},
]
def request(action: str, arguments: dict[str, Any]) -> dict[str, Any]:
payload = json.dumps({"action": action, "arguments": arguments}, ensure_ascii=False, separators=(",", ":")).encode() + b"\n"
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
client.settimeout(20)
client.connect(SOCKET_PATH)
client.sendall(payload)
chunks = bytearray()
while not chunks.endswith(b"\n"):
part = client.recv(65536)
if not part:
break
chunks.extend(part)
if len(chunks) > 2_000_000:
raise RuntimeError("operator response exceeds limit")
response = json.loads(chunks)
if not response.get("ok"):
raise RuntimeError(response.get("error", "operator request failed"))
return response["result"]
def call(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
mapping = {
"athena_operator_inspect": "inspect",
"athena_operator_read_source": "read_source",
"athena_operator_search_source": "search_source",
"athena_operator_prepare": "prepare",
"athena_operator_execute": "execute",
"athena_operator_job": "job",
}
if name not in mapping:
raise ValueError("unknown tool")
return request(mapping[name], arguments)
def emit(request_id: Any, result: Any = None, error: dict[str, Any] | None = None) -> None:
message = {"jsonrpc": "2.0", "id": request_id}
message["error" if error else "result"] = error or result
sys.stdout.write(json.dumps(message, ensure_ascii=False, separators=(",", ":")) + "\n")
sys.stdout.flush()
def handle(message: dict[str, Any]) -> None:
method, request_id = message.get("method"), message.get("id")
if method == "initialize":
emit(request_id, {"protocolVersion": message.get("params", {}).get("protocolVersion", "2024-11-05"), "capabilities": {"tools": {"listChanged": False}}, "serverInfo": {"name": "mike-ai-athena-operator", "version": VERSION}})
elif method == "tools/list":
emit(request_id, {"tools": TOOLS})
elif method == "tools/call":
params = message.get("params", {})
try:
value = call(str(params.get("name", "")), params.get("arguments") or {})
emit(request_id, {"content": [{"type": "text", "text": json.dumps(value, ensure_ascii=False, separators=(",", ":"))}], "structuredContent": value, "isError": False})
except Exception as exc:
emit(request_id, {"content": [{"type": "text", "text": f"ERROR: {exc}"}], "isError": True})
elif request_id is not None:
emit(request_id, error={"code": -32601, "message": "method not found"})
def main() -> None:
for line in sys.stdin:
try:
if line.strip(): handle(json.loads(line))
except Exception as exc:
sys.stderr.write(f"MCP input error: {exc}\n"); sys.stderr.flush()
if __name__ == "__main__":
main()
-547
View File
@@ -1,547 +0,0 @@
#!/usr/bin/env python3
"""Capability-bounded terminal MCP for the Athena source tree.
This is deliberately not a general shell. Commands are executed without a
shell, against read-only mounts, with a fixed environment and a strict program
and argument allowlist. The container has no Docker socket, host PID namespace,
SSH material, secrets or egress network.
"""
from __future__ import annotations
import ast
import json
import os
import re
import subprocess
import sys
from pathlib import Path
from typing import Any
SERVER_VERSION = "1.0.0"
WORKSPACE_ROOT = Path(os.environ.get("ATHENA_TERMINAL_WORKSPACE", "/workspace")).resolve()
RUNTIME_ROOT = Path(os.environ.get("ATHENA_TERMINAL_RUNTIME", "/runtime")).resolve()
MAX_OUTPUT_CHARS = 20_000
MAX_ARGUMENTS = 32
COMMAND_TIMEOUT_SECONDS = 8
ALLOWED_ROOTS = (WORKSPACE_ROOT, RUNTIME_ROOT)
BLOCKED_PROGRAMS = {
"ash", "bash", "busybox", "chroot", "curl", "dash", "docker", "env",
"fish", "ftp", "halt", "init", "kill", "killall", "ksh", "mount",
"nc", "netcat", "nft", "nohup", "perl", "php", "pkill", "poweroff",
"python", "python3", "reboot", "rsync", "scp", "sh", "shutdown", "ssh",
"sftp", "socat", "sudo", "su", "systemctl", "telnet", "umount", "wget",
"zsh",
}
ALLOWED_PROGRAMS = {
"cat", "df", "du", "file", "grep", "head", "ls", "rg", "sed",
"sha256sum", "stat", "tail", "wc",
}
SENSITIVE_PATH_TOKENS = {
".env", "authorized_keys", "agekey", "credentials", "id_ed25519",
"id_rsa", "private_key", "secret", "secrets", "shadow",
}
if hasattr(sys.stdin, "reconfigure"):
sys.stdin.reconfigure(encoding="utf-8", errors="replace")
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
TOOLS = [
{
"name": "athena_terminal_policy",
"description": (
"USE FIRST before terminal work on Athena. Returns the exact capability boundary, "
"allowed read-only programs, visible roots and explicitly unavailable operations. "
"This tool performs no command. The terminal is not a shell and cannot access SSH, "
"Docker control, host services, secrets, networking, shutdown or reboot."
),
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
},
{
"name": "athena_terminal_run",
"description": (
"Run one bounded read-only command against Athena's versioned stack or bounded "
"runtime snapshot. Pass a program and an argument array; shell syntax, pipelines, "
"redirection and command substitution do not exist. Allowed programs are ls, cat, "
"head, tail, wc, stat, file, sha256sum, grep, rg, sed, du and df. Paths may only be "
"inside /workspace or /runtime. Never claim this can deploy, edit, control Docker, "
"restart services, use SSH or alter the host."
),
"inputSchema": {
"type": "object",
"properties": {
"program": {"type": "string", "enum": sorted(ALLOWED_PROGRAMS)},
"arguments": {
"type": "array",
"maxItems": MAX_ARGUMENTS,
"items": {"type": "string", "maxLength": 500},
"default": [],
},
"working_directory": {
"type": "string",
"enum": ["workspace", "runtime"],
"default": "workspace",
},
},
"required": ["program"],
"additionalProperties": False,
},
},
{
"name": "athena_terminal_validate_source",
"description": (
"Validate exactly one versioned source file without executing it. Supports Python "
"AST parsing, Bash syntax-only parsing, JSON parsing and YAML parsing. The file must "
"be below /workspace and cannot be a secret-bearing path. This does not build, run, "
"deploy or modify anything."
),
"inputSchema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"minLength": 1,
"maxLength": 240,
"pattern": "^[A-Za-z0-9_./-]+$",
},
"kind": {
"type": "string",
"enum": ["auto", "python", "shell", "json", "yaml"],
"default": "auto",
},
},
"required": ["path"],
"additionalProperties": False,
},
},
]
def json_text(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
def within_root(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
return True
except ValueError:
return False
def reject_sensitive_path(path: Path) -> None:
lowered_parts = {part.casefold() for part in path.parts}
lowered_name = path.name.casefold()
if lowered_parts & SENSITIVE_PATH_TOKENS:
raise PermissionError("secret-bearing paths are not accessible")
if any(token in lowered_name for token in ("secret", "credential", "agekey", "private")):
raise PermissionError("secret-bearing paths are not accessible")
def safe_path(value: str, cwd: Path, *, must_exist: bool = True) -> Path:
if not value or "\x00" in value or "\n" in value or "\r" in value:
raise ValueError("invalid path")
candidate = Path(value)
candidate = candidate if candidate.is_absolute() else cwd / candidate
resolved = candidate.resolve(strict=False)
if not any(within_root(resolved, root) for root in ALLOWED_ROOTS):
raise PermissionError("path is outside the allowed terminal roots")
reject_sensitive_path(resolved)
if must_exist and not resolved.exists():
raise FileNotFoundError("path does not exist")
return resolved
def clean_scalar(value: str) -> str:
if not isinstance(value, str) or len(value) > 500:
raise ValueError("invalid argument")
if any(char in value for char in ("\x00", "\n", "\r")):
raise ValueError("multiline and NUL arguments are forbidden")
if any(token in value for token in ("$(", "${", "`", ">", "<", "|", ";", "&&", "||")):
raise ValueError("shell syntax is forbidden")
return value
def path_argument(value: str, cwd: Path) -> str:
if value == "-":
raise ValueError("stdin paths are not supported")
return str(safe_path(value, cwd))
def validate_arguments(program: str, arguments: list[str], cwd: Path) -> list[str]:
if program in BLOCKED_PROGRAMS or program not in ALLOWED_PROGRAMS:
raise PermissionError("program is not allowed")
if len(arguments) > MAX_ARGUMENTS:
raise ValueError("too many arguments")
args = [clean_scalar(value) for value in arguments]
if program == "cat":
if not args:
raise ValueError("cat requires at least one file")
return [path_argument(value, cwd) for value in args]
if program in {"sha256sum", "file"}:
allowed_flags = {"-b"} if program == "file" else set()
result = []
for value in args:
if value.startswith("-"):
if value not in allowed_flags:
raise ValueError("unsupported option")
result.append(value)
else:
result.append(path_argument(value, cwd))
if not any(not value.startswith("-") for value in args):
raise ValueError(f"{program} requires a path")
return result
if program in {"head", "tail"}:
result = []
index = 0
if len(args) >= 2 and args[0] == "-n":
if not re.fullmatch(r"[0-9]{1,5}", args[1]) or int(args[1]) > 2000:
raise ValueError("line count must be between 0 and 2000")
result.extend(args[:2])
index = 2
paths = args[index:]
if not paths:
raise ValueError(f"{program} requires a path")
result.extend(path_argument(value, cwd) for value in paths)
return result
if program == "wc":
allowed = {"-c", "-l", "-m", "-w"}
result = []
paths = 0
for value in args:
if value.startswith("-"):
if value not in allowed:
raise ValueError("unsupported wc option")
result.append(value)
else:
result.append(path_argument(value, cwd))
paths += 1
if paths == 0:
raise ValueError("wc requires a path")
return result
if program == "stat":
if not args:
raise ValueError("stat requires a path")
if any(value.startswith("-") for value in args):
raise ValueError("stat options are not supported")
return [path_argument(value, cwd) for value in args]
if program == "ls":
allowed = {"-1", "-a", "-al", "-d", "-h", "-l", "-la", "-lh", "-R"}
result = []
for value in args:
if value.startswith("-"):
if value not in allowed:
raise ValueError("unsupported ls option")
result.append(value)
else:
result.append(path_argument(value, cwd))
if not any(not value.startswith("-") for value in args):
result.append(str(cwd))
return result
if program == "sed":
if len(args) < 3 or args[0] != "-n":
raise ValueError("sed only supports: -n START[,END]p FILE...")
if not re.fullmatch(r"[0-9]{1,7}(,[0-9]{1,7})?p", args[1]):
raise ValueError("sed expression is limited to printing a line range")
return ["-n", args[1], *[path_argument(value, cwd) for value in args[2:]]]
if program == "grep":
# GNU grep -R follows symlinks. Only -r is permitted because it skips
# directory symlinks and therefore preserves the visible-root boundary.
allowed = {"-c", "-E", "-F", "-i", "-l", "-n", "-r"}
result = []
index = 0
while index < len(args) and args[index].startswith("-"):
if args[index] not in allowed:
raise ValueError("unsupported grep option")
result.append(args[index])
index += 1
if index >= len(args):
raise ValueError("grep requires a pattern")
result.append(args[index])
index += 1
paths = args[index:] or [str(cwd)]
result.extend(path_argument(value, cwd) for value in paths)
return result
if program == "rg":
allowed_flags = {"--files", "--hidden", "--json", "-c", "-F", "-i", "-l", "-n"}
result = []
index = 0
files_mode = False
while index < len(args) and args[index].startswith("-"):
value = args[index]
if value in {"-g", "--glob"}:
if index + 1 >= len(args):
raise ValueError("missing glob value")
result.extend([value, args[index + 1]])
index += 2
continue
if value not in allowed_flags:
raise ValueError("unsupported rg option")
files_mode = files_mode or value == "--files"
result.append(value)
index += 1
if not files_mode:
if index >= len(args):
raise ValueError("rg requires a pattern")
result.append(args[index])
index += 1
paths = args[index:] or [str(cwd)]
result.extend(path_argument(value, cwd) for value in paths)
return result
if program == "du":
allowed = {"-a", "-h", "-s", "-sh"}
result = []
for value in args:
if value.startswith("--max-depth="):
depth = value.split("=", 1)[1]
if not depth.isdigit() or int(depth) > 5:
raise ValueError("du max depth must be between 0 and 5")
result.append(value)
elif value.startswith("-"):
if value not in allowed:
raise ValueError("unsupported du option")
result.append(value)
else:
result.append(path_argument(value, cwd))
if not any(not value.startswith("-") for value in args):
result.append(str(cwd))
return result
if program == "df":
allowed = {"-h", "-T", "-hT", "-Th"}
result = []
for value in args:
if value.startswith("-"):
if value not in allowed:
raise ValueError("unsupported df option")
result.append(value)
else:
result.append(path_argument(value, cwd))
return result
raise PermissionError("program policy is incomplete")
def compact_output(text: str) -> tuple[str, bool]:
if len(text) <= MAX_OUTPUT_CHARS:
return text, False
marker = f"\n...[output truncated from {len(text)} characters]...\n"
remaining = MAX_OUTPUT_CHARS - len(marker)
return text[: int(remaining * 0.75)] + marker + text[-int(remaining * 0.25) :], True
def policy() -> dict[str, Any]:
return {
"mode": "bounded-read-only-terminal",
"allowed_programs": sorted(ALLOWED_PROGRAMS),
"visible_roots": [str(root) for root in ALLOWED_ROOTS],
"execution": "direct argv only; no shell, pipelines, redirection or substitution",
"limits": {
"timeout_seconds": COMMAND_TIMEOUT_SECONDS,
"max_arguments": MAX_ARGUMENTS,
"max_output_characters": MAX_OUTPUT_CHARS,
},
"unavailable": [
"SSH/SCP/SFTP and all remote login",
"shutdown, reboot, halt and power operations",
"Docker socket, Docker control and container exec",
"systemctl, service control, process signals and host PID namespace",
"network clients, internet access, VPN/firewall/routing changes",
"interpreters, arbitrary scripts and package installation",
"writes to the Athena stack, host filesystem, Git or secrets",
],
"instruction": (
"This terminal supplies evidence and syntax validation only. Use a separate, "
"ticket-bound operator workflow for future deployments or state changes."
),
}
def run_command(arguments: dict[str, Any]) -> dict[str, Any]:
program = str(arguments.get("program", ""))
raw_args = arguments.get("arguments") or []
if not isinstance(raw_args, list) or not all(isinstance(value, str) for value in raw_args):
raise ValueError("arguments must be a string array")
cwd_name = str(arguments.get("working_directory", "workspace"))
cwd = WORKSPACE_ROOT if cwd_name == "workspace" else RUNTIME_ROOT if cwd_name == "runtime" else None
if cwd is None or not cwd.is_dir():
raise ValueError("working directory is unavailable")
argv = [program, *validate_arguments(program, raw_args, cwd)]
environment = {
"HOME": "/nonexistent",
"LANG": "C.UTF-8",
"LC_ALL": "C.UTF-8",
"PATH": "/usr/local/bin:/usr/bin:/bin",
"PAGER": "cat",
"RIPGREP_CONFIG_PATH": "/nonexistent",
}
try:
completed = subprocess.run(
argv,
cwd=cwd,
env=environment,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
errors="replace",
timeout=COMMAND_TIMEOUT_SECONDS,
check=False,
)
except subprocess.TimeoutExpired as exc:
partial = (exc.stdout or "") + (exc.stderr or "")
output, truncated = compact_output(str(partial))
return {
"program": program,
"exit_code": None,
"timed_out": True,
"truncated": truncated,
"output": output,
"instruction": "The process was killed at the fixed timeout; do not retry in a loop.",
}
output, truncated = compact_output(completed.stdout + completed.stderr)
return {
"program": program,
"exit_code": completed.returncode,
"timed_out": False,
"truncated": truncated,
"output": output,
"read_only": True,
}
def validate_source(arguments: dict[str, Any]) -> dict[str, Any]:
relative = str(arguments.get("path", ""))
target = safe_path(relative, WORKSPACE_ROOT)
if not within_root(target, WORKSPACE_ROOT) or not target.is_file():
raise PermissionError("validation is limited to files below /workspace")
kind = str(arguments.get("kind", "auto"))
suffix = target.suffix.casefold()
if kind == "auto":
if suffix == ".py":
kind = "python"
elif suffix in {".sh", ".bash"}:
kind = "shell"
elif suffix == ".json":
kind = "json"
elif suffix in {".yaml", ".yml"}:
kind = "yaml"
else:
raise ValueError("cannot infer validation kind for this file")
text = target.read_text(encoding="utf-8", errors="strict")
if len(text) > 2_000_000:
raise ValueError("source file exceeds validation limit")
if kind == "python":
ast.parse(text, filename=str(target))
elif kind == "json":
json.loads(text)
elif kind == "yaml":
import yaml
yaml.safe_load(text)
elif kind == "shell":
completed = subprocess.run(
["/bin/bash", "-n", str(target)],
env={"PATH": "/usr/bin:/bin", "LANG": "C.UTF-8"},
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=COMMAND_TIMEOUT_SECONDS,
check=False,
)
if completed.returncode != 0:
raise ValueError(compact_output(completed.stderr)[0])
else:
raise ValueError("unsupported validation kind")
return {
"path": str(target.relative_to(WORKSPACE_ROOT)),
"kind": kind,
"valid": True,
"executed": False,
"modified": False,
}
def call_tool(name: str, arguments: dict[str, Any]) -> str:
if name == "athena_terminal_policy":
result = policy()
elif name == "athena_terminal_run":
result = run_command(arguments)
elif name == "athena_terminal_validate_source":
result = validate_source(arguments)
else:
raise ValueError(f"unknown tool: {name}")
return json_text(result)
def response(request_id: Any, result: Any = None, error: dict[str, Any] | None = None) -> None:
payload: dict[str, Any] = {"jsonrpc": "2.0", "id": request_id}
payload["error" if error is not None else "result"] = error if error is not None else result
sys.stdout.write(json_text(payload) + "\n")
sys.stdout.flush()
def handle(message: dict[str, Any]) -> None:
method = message.get("method")
request_id = message.get("id")
if method == "initialize":
response(
request_id,
{
"protocolVersion": message.get("params", {}).get("protocolVersion", "2024-11-05"),
"capabilities": {"tools": {"listChanged": False}},
"serverInfo": {"name": "mike-ai-athena-terminal", "version": SERVER_VERSION},
},
)
elif method == "tools/list":
response(request_id, {"tools": TOOLS})
elif method == "tools/call":
params = message.get("params", {})
try:
text = call_tool(str(params.get("name", "")), params.get("arguments") or {})
response(
request_id,
{
"content": [{"type": "text", "text": text}],
"structuredContent": json.loads(text),
"isError": False,
},
)
except Exception as exc:
response(
request_id,
{
"content": [{"type": "text", "text": f"ERROR: {exc}"}],
"isError": True,
},
)
elif request_id is not None:
response(request_id, error={"code": -32601, "message": f"Method not found: {method}"})
def main() -> None:
for line in sys.stdin:
try:
if line.strip():
handle(json.loads(line))
except Exception as exc:
sys.stderr.write(f"MCP input error: {exc}\n")
sys.stderr.flush()
if __name__ == "__main__":
main()
+9 -11
View File
@@ -166,22 +166,20 @@ services:
retries: 5
start_period: 10s
mcp-athena-terminal:
mcp-athena-operator:
<<: *tool-common
build:
context: .
dockerfile: Dockerfile.athena-terminal
image: mike-ai/mcp-athena-terminal:1.0.0
container_name: mike-ai-mcp-athena-terminal
dockerfile: Dockerfile.athena-operator
image: mike-ai/mcp-athena-operator:1.0.0
container_name: mike-ai-mcp-athena-operator
environment:
ATHENA_TERMINAL_WORKSPACE: /workspace
ATHENA_TERMINAL_RUNTIME: /runtime
ATHENA_OPERATOR_SOCKET: /operator/operator.sock
volumes:
# The terminal sees only versioned source and the bounded, payload-free
# runtime snapshot. It receives no Docker socket, host filesystem,
# secrets, SSH material, devices, PID namespace or egress network.
- ${PLATFORM_STACK_DIR:-/opt/mike-ai/stack}:/workspace:ro
- ${PLATFORM_CONTEXT_RUNTIME_DIR:-/var/lib/mike-ai-platform-context}:/runtime:ro
# The unprivileged MCP facade sees only the root-owned executor socket.
# Docker, source, models, Git credentials and host paths remain on the
# executor side and are reachable only through structured operations.
- /run/mike-ai-operator:/operator:ro
networks: [tools]
healthcheck:
test: ["CMD", "python", "-c", "import socket; s=socket.create_connection(('127.0.0.1',8000),2); s.close()"]
+5
View File
@@ -34,6 +34,11 @@ systemctl daemon-reload
systemctl enable --now mike-ai-platform-context-snapshot.timer
systemctl start mike-ai-platform-context-snapshot.service
# One user-facing Athena Operator MCP controls the complete local AI platform
# through a root-side structured executor. It is intentionally not a general
# shell and exposes no raw Docker socket or host paths to the MCP container.
"$MCP_DIR/../operator/install-operator.sh"
profiles=()
if [[ -s /etc/mike-ai/homeassistant-admin-mcp.env ]]; then
profiles+=(--profile homeassistant)
@@ -1,7 +1,7 @@
"""
title: MikeAI Auto Tool Selector
author: MikeAI
version: 1.1.0
version: 2.0.0
description: Selects a small, relevant set of MCP servers for each user request.
"""
@@ -27,7 +27,7 @@ class Filter:
"unraid": "server:mcp:unraid-readonly-local",
"navidrome": "server:mcp:navidrome-local",
"platform": "server:mcp:athena-platform",
"terminal": "server:mcp:athena-terminal-local",
"operator": "server:mcp:athena-operator-local",
}
LABELS = {
@@ -38,7 +38,7 @@ class Filter:
"unraid": "Unraid-Diagnose",
"navidrome": "Navidrome",
"platform": "Athena-Plattformwissen",
"terminal": "Athena-Terminal (begrenzt, nur lesend)",
"operator": "Athena Operator",
}
def __init__(self):
@@ -96,13 +96,14 @@ class Filter:
selected: list[str] = []
terminal = self._matches(
operator = self._matches(
text,
(
r"\bathena[- ]terminal\b",
r"\b(?:athena|ki[- ]host)\b.*\b(?:terminal|shell|kommando|befehl)\b",
r"\b(?:terminal|shell)\b.*\b(?:athena|ki[- ]host)\b",
r"\b(?:ls|tail|grep|rg|cat)\b.*\b(?:athena|ki[- ]host)\b",
r"\bathena[- ]operator\b",
r"\b(?:athena|ki[- ]host)\b.*\b(?:bau|[aä]nder|deploy|install|lad|test|start|stop|restart|konfigurier|aktualisier|entfern|erstell|implementier|entwickel)\w*\b",
r"\b(?:bau|[aä]nder|deploy|install|lad|test|start|stop|restart|konfigurier|aktualisier|entfern|erstell|implementier|entwickel)\w*\b.*\b(?:athena|ki[- ]host)\b",
r"\bmcp\b.*\b(?:bau\w*|implementier\w*|entwickel\w*|integrier\w*)\b",
r"\b(?:bau\w*|implementier\w*|entwickel\w*|integrier\w*)\b.*\bmcp\b",
),
)
platform = self._matches(
@@ -114,8 +115,6 @@ class Filter:
r"\b(?:disaster|bare metal)[- ]recovery\b",
r"\b(?:installations?|reinstall|setup)[- ]skript\b",
r"\bplattform(?:wissen|dokumentation)?\b",
r"\bmcp\b.*\b(?:bau\w*|implementier\w*|entwickel\w*|integrier\w*)\b",
r"\b(?:bau\w*|implementier\w*|entwickel\w*|integrier\w*)\b.*\bmcp\b",
),
)
homeassistant = self._matches(
@@ -168,8 +167,8 @@ class Filter:
# A specialist source is more precise than public web search. Platform
# wins over a generic Docker mention when Athena is explicitly named.
if terminal:
selected.append("terminal")
if operator:
selected.append("operator")
elif platform:
selected.append("platform")
elif homeassistant:
+27 -14
View File
@@ -165,6 +165,18 @@ with con:
connections = json.loads(row[0])
if not isinstance(connections, list):
raise SystemExit("Unbekanntes Format in tool_server.connections.")
before_count = len(connections)
connections = [
connection for connection in connections
if not (
isinstance(connection, dict)
and (
str((connection.get("info") or {}).get("id", "")).lower()
== "athena-terminal-local"
or "mike-ai-mcp-athena-terminal" in str(connection.get("url", "")).lower()
)
)
]
descriptions = {
"web-local": (
"Web (öffentlich, read-only)",
@@ -212,15 +224,16 @@ with con:
"Quellcode dieses Werkzeug statt allgemeiner Websuche verwenden. Keine Issues, "
"Pull Requests, Actions oder Schreibzugriffe.",
),
"athena-terminal-local": (
"Athena Terminal (begrenzt, nur lesend)",
"Nur für gezielte Beweissuche in Athenas versioniertem Stack und einem "
"begrenzten Laufzeitsnapshot. Unterstützt ausschließlich fest erlaubte "
"Lesebefehle und Syntaxprüfung. Kein SSH, Docker, Netzwerk, Schreibzugriff, "
"Reboot, Shutdown, systemctl, Interpreter oder Zugriff auf Secrets.",
"athena-operator-local": (
"Athena Operator",
"Zentrale Bedienebene für Athenas KI-Plattform: MCPs entwickeln und deployen, "
"Docker-Dienste verwalten, Modelle laden und testen, Profile/OpenWebUI ändern, "
"prüfen, dokumentieren, versionieren und Recovery erzeugen. Änderungen benötigen "
"eine inhaltlich gebundene Vorschau und ausdrückliche Bestätigung. Kein freies "
"Terminal und keine SSH-, Netzwerk-, Boot-, Reboot- oder Shutdown-Änderungen.",
),
}
changed = False
changed = len(connections) != before_count
for connection in connections:
if not isinstance(connection, dict):
continue
@@ -244,8 +257,8 @@ with con:
match = "navidrome-local"
elif "mike-ai-mcp-github" in url:
match = "github-local"
elif "mike-ai-mcp-athena-terminal" in url:
match = "athena-terminal-local"
elif "mike-ai-mcp-athena-operator" in url:
match = "athena-operator-local"
elif "mike-ai-mcp-unraid-official" in url:
match = "unraid-readonly-local"
else:
@@ -287,16 +300,16 @@ with con:
isinstance(connection, dict)
and (
str(connection.get("url", "")).lower()
== "http://mike-ai-mcp-athena-terminal:8000/mcp"
== "http://mike-ai-mcp-athena-operator:8000/mcp"
or str((connection.get("info") or {}).get("id", "")).lower()
== "athena-terminal-local"
== "athena-operator-local"
)
for connection in connections
):
name, description = descriptions["athena-terminal-local"]
name, description = descriptions["athena-operator-local"]
connections.append(
{
"url": "http://mike-ai-mcp-athena-terminal:8000/mcp",
"url": "http://mike-ai-mcp-athena-operator:8000/mcp",
"path": "",
"type": "mcp",
"auth_type": "none",
@@ -304,7 +317,7 @@ with con:
"key": "",
"config": {"enable": True, "access_grants": []},
"info": {
"id": "athena-terminal-local",
"id": "athena-operator-local",
"name": name,
"description": description,
},
+20
View File
@@ -0,0 +1,20 @@
[Unit]
Description=MikeAI Athena platform operator executor
After=docker.service data.mount network-online.target
Wants=docker.service network-online.target
[Service]
Type=simple
EnvironmentFile=-/etc/mike-ai/athena-operator.env
ExecStart=/usr/local/libexec/mike-ai-athena-operatord
Restart=on-failure
RestartSec=3
UMask=0077
NoNewPrivileges=yes
PrivateTmp=yes
ProtectHome=read-only
ProtectSystem=full
ReadWritePaths=/opt/mike-ai/stack /data/mike-ai-operator /data/models /run/mike-ai-operator /data
[Install]
WantedBy=multi-user.target
+506
View File
@@ -0,0 +1,506 @@
#!/usr/bin/env python3
"""Root-side executor for the single Athena Operator MCP.
The daemon exposes structured platform operations over a local Unix socket.
It deliberately has no arbitrary-command endpoint. Every mutation is first
materialised as an expiring, content-bound proposal and requires its exact
confirmation string in a later call.
"""
from __future__ import annotations
import difflib
import hashlib
import json
import os
import re
import shutil
import socketserver
import subprocess
import tempfile
import threading
import time
import urllib.parse
import urllib.request
import uuid
from pathlib import Path
from typing import Any
VERSION = "1.0.0"
STACK = Path(os.environ.get("ATHENA_OPERATOR_STACK", "/opt/mike-ai/stack")).resolve()
REPOSITORY = Path(os.environ.get("ATHENA_OPERATOR_REPOSITORY", "/data/mike-ai-operator/repository")).resolve()
STATE = Path(os.environ.get("ATHENA_OPERATOR_STATE", "/data/mike-ai-operator/state")).resolve()
SOCKET = Path(os.environ.get("ATHENA_OPERATOR_SOCKET", "/run/mike-ai-operator/operator.sock"))
MODELS = Path(os.environ.get("ATHENA_OPERATOR_MODELS", "/data/models")).resolve()
TICKET_TTL = 1800
MAX_FILE_BYTES = 1_000_000
MAX_FILES = 24
MAX_OUTPUT = 30_000
LOCK = threading.RLock()
SAFE_PATH = re.compile(r"^[A-Za-z0-9_.+/-]{1,240}$")
SAFE_NAME = re.compile(r"^[A-Za-z0-9_.-]{1,100}$")
SAFE_COMMIT = re.compile(r"^[A-Za-z0-9ÄÖÜäöüß _.,:;()+/\-]{5,120}$")
BLOCKED_PATH_PARTS = {".git", ".ssh", "secrets", "credentials", "authorized_keys"}
PROTECTED_CONTAINERS = {
"mike-ai-wireguard-gateway",
}
ALLOWED_OPERATIONS = {
"file_update", "run_checks", "compose_deploy", "container_action",
"openwebui_sync", "git_publish", "model_download", "benchmark", "recovery",
}
ALLOWED_CHECKS = {
"operator-tests": ["python3", "dev/test_athena_operator.py"],
"openwebui-filter-tests": ["python3", "dev/test_openwebui_filters.py"],
"platform-verify": ["bash", "platform/checks/verify-platform.sh"],
"compose-main": ["docker", "compose", "-f", "compose.yaml", "config", "-q"],
"compose-mcp": ["docker", "compose", "-f", "platform/mcp/compose.yaml", "config", "-q"],
}
def now() -> int:
return int(time.time())
def compact(text: str) -> str:
if len(text) <= MAX_OUTPUT:
return text
return text[:22_000] + f"\n...[truncated from {len(text)} chars]...\n" + text[-7_000:]
def run(argv: list[str], *, cwd: Path = STACK, timeout: int = 900, check: bool = False) -> dict[str, Any]:
completed = subprocess.run(
argv, cwd=cwd, env={"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C.UTF-8", "HOME": "/root"},
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, errors="replace", timeout=timeout, check=False,
)
result = {"argv": argv, "exit_code": completed.returncode, "output": compact(completed.stdout)}
if check and completed.returncode != 0:
raise RuntimeError(json.dumps(result, ensure_ascii=False))
return result
def safe_relative(value: str) -> Path:
if not SAFE_PATH.fullmatch(value or "") or value.startswith("/"):
raise ValueError("invalid repository-relative path")
path = Path(value)
if ".." in path.parts or any(part.casefold() in BLOCKED_PATH_PARTS for part in path.parts):
raise PermissionError("protected path")
resolved = (REPOSITORY / path).resolve(strict=False)
resolved.relative_to(REPOSITORY)
return path
def source_file(root: Path, relative: Path) -> Path:
candidate = (root / relative).resolve(strict=False)
candidate.relative_to(root)
return candidate
def sha(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def json_write(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(temporary, path)
def audit(event: str, **fields: Any) -> None:
record = {"time": now(), "event": event, **fields}
path = STATE / "audit.jsonl"
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n")
def ensure_repository() -> None:
if not (REPOSITORY / ".git").is_dir():
bundle = Path("/data/mike-ai-recovery-kit/source.git.bundle")
if not bundle.is_file():
raise RuntimeError("operator repository missing and no recovery Git bundle is available")
REPOSITORY.parent.mkdir(parents=True, exist_ok=True)
run(["git", "clone", str(bundle), str(REPOSITORY)], cwd=Path("/data"), check=True)
current_file = STACK / ".mike-ai-source-commit"
current = current_file.read_text().strip() if current_file.is_file() else ""
if re.fullmatch(r"[0-9a-f]{40}", current):
run(["git", "switch", "-C", "main", current], cwd=REPOSITORY, check=True)
remote = os.environ.get("ATHENA_OPERATOR_GIT_REMOTE", "").strip()
if remote:
run(["git", "remote", "set-url", "origin", remote], cwd=REPOSITORY, check=True)
run(["git", "config", "user.name", os.environ.get("ATHENA_OPERATOR_GIT_NAME", "Athena Operator")], cwd=REPOSITORY, check=True)
run(["git", "config", "user.email", os.environ.get("ATHENA_OPERATOR_GIT_EMAIL", "athena-operator@localhost")], cwd=REPOSITORY, check=True)
def inspect(subject: str, arguments: dict[str, Any]) -> dict[str, Any]:
ensure_repository()
if subject == "overview":
return {
"version": VERSION,
"source_commit": (STACK / ".mike-ai-source-commit").read_text().strip(),
"git": run(["git", "status", "--short", "--branch"], cwd=REPOSITORY),
"containers": run(["docker", "ps", "--format", "{{.Names}}\t{{.Status}}\t{{.Image}}"]),
"storage": run(["df", "-h", "/", "/data", str(MODELS)]),
"gpus": run(["nvidia-smi", "--query-gpu=name,memory.total,memory.used,utilization.gpu", "--format=csv,noheader"]),
"boundary": "Athena AI-platform operator; no arbitrary shell, shutdown, reboot, SSH/network/firewall/kernel/driver/partition operations",
}
if subject == "git_status":
return {"status": run(["git", "status", "--short", "--branch"], cwd=REPOSITORY), "diff": run(["git", "diff", "--stat"], cwd=REPOSITORY)}
if subject == "containers":
return {"containers": run(["docker", "ps", "-a", "--format", "{{.Names}}\t{{.Status}}\t{{.Image}}"])}
if subject == "models":
entries = []
if MODELS.is_dir():
for path in sorted(MODELS.rglob("*.gguf")):
entries.append({"path": str(path.relative_to(MODELS)), "bytes": path.stat().st_size})
return {"models": entries[:500], "count": len(entries)}
if subject == "jobs":
jobs = []
for path in sorted((STATE / "jobs").glob("*.json"), reverse=True)[:30]:
jobs.append(json.loads(path.read_text()))
return {"jobs": jobs}
raise ValueError("unsupported inspection subject")
def read_source(arguments: dict[str, Any]) -> dict[str, Any]:
ensure_repository()
relative = safe_relative(str(arguments.get("path", "")))
target = source_file(REPOSITORY, relative)
if not target.is_file():
raise FileNotFoundError("source file not found")
text = target.read_text(encoding="utf-8", errors="replace")
start = max(1, int(arguments.get("start_line", 1)))
count = min(1000, max(1, int(arguments.get("line_count", 300))))
lines = text.splitlines()
return {"path": str(relative), "sha256": sha(target.read_bytes()), "start_line": start, "content": "\n".join(lines[start - 1:start - 1 + count]), "total_lines": len(lines)}
def search_source(arguments: dict[str, Any]) -> dict[str, Any]:
ensure_repository()
query = str(arguments.get("query", ""))
if not query or len(query) > 200 or any(x in query for x in ("\x00", "\n", "\r")):
raise ValueError("invalid query")
result = run(["rg", "-n", "--hidden", "--glob", "!.git/**", "--", query, "."], cwd=REPOSITORY, timeout=20)
return {"query": query, "matches": result["output"], "exit_code": result["exit_code"]}
def normalise_operation(operation: str, payload: dict[str, Any]) -> tuple[dict[str, Any], str]:
if operation not in ALLOWED_OPERATIONS:
raise ValueError("unsupported operation")
if not isinstance(payload, dict):
raise ValueError("payload must be an object")
if operation == "file_update":
files = payload.get("files")
if not isinstance(files, list) or not 1 <= len(files) <= MAX_FILES:
raise ValueError("files must contain 1..24 entries")
normal = []
previews = []
for item in files:
relative = safe_relative(str(item.get("path", "")))
content = str(item.get("content", ""))
raw = content.encode()
if len(raw) > MAX_FILE_BYTES:
raise ValueError("file content exceeds limit")
target = source_file(REPOSITORY, relative)
before = target.read_text(encoding="utf-8", errors="replace") if target.is_file() else ""
expected = str(item.get("expected_sha256", ""))
before_sha = sha(before.encode())
if expected and expected != before_sha:
raise RuntimeError(f"source drift for {relative}")
diff = "".join(difflib.unified_diff(before.splitlines(True), content.splitlines(True), fromfile=f"a/{relative}", tofile=f"b/{relative}"))
normal.append({"path": str(relative), "content": content, "before_sha256": before_sha, "after_sha256": sha(raw)})
previews.append(compact(diff))
return {"files": normal}, "\n".join(previews)
if operation == "run_checks":
checks = payload.get("checks") or []
if not isinstance(checks, list) or not checks or any(name not in ALLOWED_CHECKS for name in checks):
raise ValueError("unknown check suite")
return {"checks": checks}, "Will run: " + ", ".join(checks)
if operation == "compose_deploy":
compose_file = str(payload.get("compose_file", ""))
if compose_file not in {"compose.yaml", "platform/mcp/compose.yaml"}:
raise ValueError("unsupported compose file")
services = payload.get("services") or []
if not isinstance(services, list) or not 1 <= len(services) <= 12 or any(not SAFE_NAME.fullmatch(str(x)) for x in services):
raise ValueError("invalid compose service list")
return {"compose_file": compose_file, "services": services, "build": bool(payload.get("build", True))}, f"docker compose -f {compose_file} up -d {'--build ' if payload.get('build', True) else ''}{' '.join(services)}"
if operation == "container_action":
action = str(payload.get("action", ""))
containers = payload.get("containers") or []
if action not in {"start", "stop", "restart"}:
raise ValueError("invalid container action")
if not isinstance(containers, list) or not 1 <= len(containers) <= 12:
raise ValueError("invalid container list")
for name in containers:
if not SAFE_NAME.fullmatch(str(name)) or not str(name).startswith("mike-ai-") or name in PROTECTED_CONTAINERS:
raise PermissionError(f"container is outside operator boundary: {name}")
return {"action": action, "containers": containers}, f"docker {action} {' '.join(containers)}"
if operation == "openwebui_sync":
return {}, (
"Synchronise the versioned Open WebUI model profiles, filters, tool connections "
"and system prompt with the running Open WebUI instance."
)
if operation == "git_publish":
message = str(payload.get("message", ""))
if not SAFE_COMMIT.fullmatch(message):
raise ValueError("invalid commit message")
status = run(["git", "status", "--short"], cwd=REPOSITORY, check=True)["output"].strip()
if not status:
raise RuntimeError("repository has no changes to publish")
diff = run(["git", "diff", "--stat"], cwd=REPOSITORY, check=True)["output"]
preview = f"Commit message: {message}\n\nChanged and untracked files:\n{status}\n\nDiff summary:\n{diff}"
return {"message": message, "reviewed_status": status}, preview
if operation == "model_download":
url = str(payload.get("url", ""))
parsed = urllib.parse.urlparse(url)
if parsed.scheme != "https" or parsed.hostname not in {"huggingface.co", "cdn-lfs.huggingface.co", "hf.co"}:
raise PermissionError("only HTTPS Hugging Face downloads are allowed")
destination = safe_relative(str(payload.get("destination", "")))
expected = str(payload.get("sha256", ""))
if expected and not re.fullmatch(r"[0-9a-f]{64}", expected):
raise ValueError("invalid sha256")
return {"url": url, "destination": str(destination), "sha256": expected}, f"Download {url} to models/{destination}"
if operation == "benchmark":
script = safe_relative(str(payload.get("script", "")))
if not (str(script).startswith("dev/") or str(script).startswith("platform/bench")) or script.suffix not in {".py", ".sh"}:
raise PermissionError("benchmark script must be versioned below dev/ or platform/bench*")
args = payload.get("arguments") or []
if not isinstance(args, list) or len(args) > 20 or any(not isinstance(x, str) or len(x) > 200 or re.search(r"[\x00\n\r]", x) for x in args):
raise ValueError("invalid benchmark arguments")
return {"script": str(script), "arguments": args}, f"Run versioned benchmark {script} with {args!r}"
if operation == "recovery":
label = str(payload.get("label", time.strftime("%Y%m%d")))
if not SAFE_NAME.fullmatch(label):
raise ValueError("invalid recovery label")
return {"label": label}, f"Create encrypted recovery bundle and self-contained data kit: {label}"
raise AssertionError(operation)
def prepare(arguments: dict[str, Any]) -> dict[str, Any]:
ensure_repository()
operation = str(arguments.get("operation", ""))
payload, preview = normalise_operation(operation, arguments.get("payload") or {})
ticket_id = uuid.uuid4().hex
record = {"id": ticket_id, "operation": operation, "payload": payload, "preview": preview, "created": now(), "expires": now() + TICKET_TTL, "status": "pending"}
record["binding"] = sha(json.dumps({"operation": operation, "payload": payload}, sort_keys=True, ensure_ascii=False).encode())
json_write(STATE / "pending" / f"{ticket_id}.json", record)
audit("prepared", ticket=ticket_id, operation=operation, binding=record["binding"])
return {"ticket": ticket_id, "operation": operation, "binding": record["binding"], "preview": preview, "expires_in_seconds": TICKET_TTL, "required_confirmation": f"EXECUTE {ticket_id}", "instruction": "Show the full preview to the user and wait for explicit confirmation in a later message."}
def sync_file(relative: Path, content: str, backup_root: Path) -> None:
for root in (REPOSITORY, STACK):
target = source_file(root, relative)
if target.exists():
backup = backup_root / root.name / relative
backup.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(target, backup)
target.parent.mkdir(parents=True, exist_ok=True)
fd, temp_name = tempfile.mkstemp(prefix=f".{target.name}.", dir=target.parent)
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(content)
os.replace(temp_name, target)
def start_job(ticket: str, operation: str, worker) -> dict[str, Any]:
job_id = uuid.uuid4().hex
job_path = STATE / "jobs" / f"{job_id}.json"
json_write(job_path, {"id": job_id, "ticket": ticket, "operation": operation, "status": "running", "started": now()})
def wrapped():
try:
result = worker()
json_write(job_path, {"id": job_id, "ticket": ticket, "operation": operation, "status": "completed", "started": json.loads(job_path.read_text())["started"], "finished": now(), "result": result})
audit("job_completed", job=job_id, operation=operation)
except Exception as exc:
json_write(job_path, {"id": job_id, "ticket": ticket, "operation": operation, "status": "failed", "finished": now(), "error": compact(str(exc))})
audit("job_failed", job=job_id, operation=operation)
threading.Thread(target=wrapped, daemon=True).start()
return {"job_id": job_id, "status": "running", "instruction": "Poll athena_operator_job until completed or failed."}
def execute_operation(ticket: str, operation: str, payload: dict[str, Any]) -> dict[str, Any]:
if operation == "file_update":
backup = STATE / "backups" / f"{now()}-{ticket}"
for item in payload["files"]:
relative = safe_relative(item["path"])
current = source_file(REPOSITORY, relative)
current_sha = sha(current.read_bytes()) if current.is_file() else sha(b"")
if current_sha != item["before_sha256"]:
raise RuntimeError(f"source drift after preview: {relative}")
for item in payload["files"]:
sync_file(safe_relative(item["path"]), item["content"], backup)
return {"changed": [item["path"] for item in payload["files"]], "backup": str(backup), "git_diff": run(["git", "diff", "--stat"], cwd=REPOSITORY)}
if operation == "run_checks":
return {"checks": [{"name": name, **run(ALLOWED_CHECKS[name], cwd=REPOSITORY, timeout=1200)} for name in payload["checks"]]}
if operation == "compose_deploy":
compose = payload["compose_file"]
run(["docker", "compose", "-f", compose, "config", "-q"], cwd=STACK, check=True)
argv = ["docker", "compose", "-f", compose, "up", "-d"]
if payload["build"]:
argv.append("--build")
argv.extend(payload["services"])
return {"deploy": run(argv, cwd=STACK, timeout=3600, check=True), "containers": run(["docker", "ps", "--format", "{{.Names}}\t{{.Status}}"])}
if operation == "container_action":
return {"action": run(["docker", payload["action"], *payload["containers"]], timeout=300, check=True)}
if operation == "openwebui_sync":
installer = STACK / "platform/openwebui/install-filters.sh"
if not installer.is_file():
raise FileNotFoundError("versioned Open WebUI synchronisation script is missing")
return {"sync": run(["bash", str(installer)], cwd=STACK, timeout=1800, check=True)}
if operation == "git_publish":
current_status = run(["git", "status", "--short"], cwd=REPOSITORY, check=True)["output"].strip()
if current_status != payload["reviewed_status"]:
raise RuntimeError("repository changed after the Git publish preview")
run(["git", "add", "--all"], cwd=REPOSITORY, check=True)
run(["git", "diff", "--cached", "--check"], cwd=REPOSITORY, check=True)
commit = run(["git", "commit", "-m", payload["message"]], cwd=REPOSITORY, check=True)
pushed = run(["git", "push", "origin", "HEAD:main"], cwd=REPOSITORY, timeout=300, check=True)
head = run(["git", "rev-parse", "HEAD"], cwd=REPOSITORY, check=True)["output"].strip()
(STACK / ".mike-ai-source-commit").write_text(head + "\n")
return {"commit": head, "commit_output": commit, "push_output": pushed}
if operation == "model_download":
def download():
destination = source_file(MODELS, Path(payload["destination"]))
destination.parent.mkdir(parents=True, exist_ok=True)
partial = destination.with_suffix(destination.suffix + ".partial")
request = urllib.request.Request(payload["url"], headers={"User-Agent": "Athena-Operator/1"})
digest = hashlib.sha256()
total = 0
with urllib.request.urlopen(request, timeout=60) as response, partial.open("wb") as output:
while chunk := response.read(8 * 1024 * 1024):
output.write(chunk); digest.update(chunk); total += len(chunk)
actual = digest.hexdigest()
if payload["sha256"] and actual != payload["sha256"]:
partial.unlink(missing_ok=True); raise RuntimeError("model checksum mismatch")
os.replace(partial, destination)
return {"destination": str(destination), "bytes": total, "sha256": actual}
return start_job(ticket, operation, download)
if operation == "benchmark":
def benchmark():
script = source_file(REPOSITORY, safe_relative(payload["script"]))
interpreter = "python3" if script.suffix == ".py" else "bash"
return run([interpreter, str(script), *payload["arguments"]], cwd=REPOSITORY, timeout=86400)
return start_job(ticket, operation, benchmark)
if operation == "recovery":
def recovery():
label = payload["label"]
dirty = run(["git", "status", "--porcelain"], cwd=REPOSITORY, check=True)["output"].strip()
if dirty:
raise RuntimeError("publish repository changes before creating a recovery kit")
head = run(["git", "rev-parse", "HEAD"], cwd=REPOSITORY, check=True)["output"].strip()
marker = (STACK / ".mike-ai-source-commit").read_text().strip()
if marker != head:
raise RuntimeError("deployed source marker and operator repository HEAD differ")
encrypted = Path(f"/data/athena-recovery-{label}.tar.age")
source_bundle = Path(f"/data/athena-source-{label}.git.bundle")
release = Path(f"/data/mike-ai-recovery-kit-{label}")
for target in (encrypted, source_bundle, release):
if target.exists():
raise FileExistsError(target)
git_bundle = run(["git", "bundle", "create", str(source_bundle), "--all"], cwd=REPOSITORY, timeout=1800, check=True)
encrypted_result = run([str(STACK / "platform/recovery/create-recovery-bundle.sh"), str(encrypted)], timeout=7200, check=True)
identity = Path("/data/mike-ai-recovery-kit/recovery.agekey")
if not identity.is_file():
raise RuntimeError("existing recovery identity is unavailable")
kit_result = run([
str(STACK / "platform/recovery/create-self-contained-data-kit.sh"),
str(encrypted), str(identity), str(source_bundle), str(release),
], timeout=7200, check=True)
verify = run(["sha256sum", "-c", "SHA256SUMS"], cwd=release, timeout=1800, check=True)
return {
"commit": head,
"recovery_bundle": str(encrypted),
"source_bundle": str(source_bundle),
"self_contained_kit": str(release),
"git_bundle": git_bundle,
"encrypted_bundle": encrypted_result,
"kit": kit_result,
"verification": verify,
}
return start_job(ticket, operation, recovery)
raise AssertionError(operation)
def execute(arguments: dict[str, Any]) -> dict[str, Any]:
ticket = str(arguments.get("ticket", ""))
confirmation = str(arguments.get("confirmation", ""))
if not re.fullmatch(r"[0-9a-f]{32}", ticket) or confirmation != f"EXECUTE {ticket}":
raise PermissionError("exact ticket confirmation required")
path = STATE / "pending" / f"{ticket}.json"
if not path.is_file():
raise ValueError("ticket not found or already consumed")
with LOCK:
record = json.loads(path.read_text())
if record["status"] != "pending" or now() > record["expires"]:
raise PermissionError("ticket expired or unavailable")
expected = sha(json.dumps({"operation": record["operation"], "payload": record["payload"]}, sort_keys=True, ensure_ascii=False).encode())
if expected != record["binding"]:
raise RuntimeError("ticket binding mismatch")
record["status"] = "executing"
json_write(path, record)
try:
result = execute_operation(ticket, record["operation"], record["payload"])
record["status"] = "executed"
record["executed"] = now()
record["result_summary"] = compact(json.dumps(result, ensure_ascii=False))
completed = STATE / "completed" / path.name
json_write(completed, record)
path.unlink()
audit("executed", ticket=ticket, operation=record["operation"], binding=record["binding"])
return {"ticket": ticket, "operation": record["operation"], "result": result, "instruction": "Verify health and Git/recovery state before declaring the work complete."}
except Exception:
record["status"] = "failed"
json_write(path, record)
raise
def job(arguments: dict[str, Any]) -> dict[str, Any]:
job_id = str(arguments.get("job_id", ""))
if not re.fullmatch(r"[0-9a-f]{32}", job_id):
raise ValueError("invalid job id")
path = STATE / "jobs" / f"{job_id}.json"
if not path.is_file():
raise FileNotFoundError("job not found")
return json.loads(path.read_text())
def dispatch(request: dict[str, Any]) -> dict[str, Any]:
action = request.get("action")
arguments = request.get("arguments") or {}
if action == "inspect": return inspect(str(arguments.get("subject", "overview")), arguments)
if action == "read_source": return read_source(arguments)
if action == "search_source": return search_source(arguments)
if action == "prepare": return prepare(arguments)
if action == "execute": return execute(arguments)
if action == "job": return job(arguments)
raise ValueError("unsupported operator action")
class Handler(socketserver.StreamRequestHandler):
def handle(self) -> None:
line = self.rfile.readline(2_000_000)
try:
request = json.loads(line)
result = dispatch(request)
response = {"ok": True, "result": result}
except Exception as exc:
response = {"ok": False, "error": compact(str(exc))}
self.wfile.write((json.dumps(response, ensure_ascii=False, separators=(",", ":")) + "\n").encode())
def main() -> None:
STATE.mkdir(parents=True, exist_ok=True)
SOCKET.parent.mkdir(parents=True, exist_ok=True)
SOCKET.unlink(missing_ok=True)
server = socketserver.ThreadingUnixStreamServer(str(SOCKET), Handler)
os.chmod(SOCKET, 0o660)
os.chown(SOCKET, 0, int(os.environ.get("ATHENA_OPERATOR_GID", "10003")))
audit("daemon_started", version=VERSION)
server.serve_forever()
if __name__ == "__main__":
main()
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -Eeuo pipefail
[[ $EUID -eq 0 ]] || { echo "Bitte als root ausführen." >&2; exit 1; }
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
install -d -m 0700 /etc/mike-ai
if [[ ! -e /etc/mike-ai/athena-operator.env ]]; then
install -m 0600 "$ROOT/config/athena-operator.env.example" /etc/mike-ai/athena-operator.env
fi
install -d -m 0750 -o root -g 10003 /run/mike-ai-operator
install -d -m 0700 /data/mike-ai-operator/state /data/mike-ai-operator/repository
install -m 0755 "$ROOT/platform/operator/athena_operatord.py" /usr/local/libexec/mike-ai-athena-operatord
install -m 0644 "$ROOT/platform/operator/athena-operator.service" /etc/systemd/system/mike-ai-athena-operator.service
systemctl daemon-reload
systemctl enable --now mike-ai-athena-operator.service
systemctl is-active --quiet mike-ai-athena-operator.service
echo ATHENA_OPERATOR_EXECUTOR_OK