From 91d36a98e39206ce5dbf6e6080150a361d6dce67 Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:38:57 +0200 Subject: [PATCH] Add bounded read-only Athena terminal MCP --- dev/test_athena_terminal_mcp.py | 97 ++++ dev/test_openwebui_filters.py | 17 + docs/COMPONENTS.md | 1 + docs/CURRENT_REFERENCE.md | 8 + docs/DISASTER_RECOVERY.md | 8 +- docs/PLATFORM_OVERVIEW.md | 6 + docs/QWEN_OPERATOR_CONTEXT.md | 8 + docs/SECURITY.md | 6 +- platform/checks/verify-platform.sh | 3 +- platform/mcp/Dockerfile.athena-terminal | 20 + platform/mcp/README.md | 14 +- platform/mcp/athena_terminal_mcp.py | 545 ++++++++++++++++++ platform/mcp/compose.yaml | 24 + .../openwebui/filters/auto_tool_selector.py | 19 +- platform/openwebui/install-filters.sh | 37 ++ 15 files changed, 806 insertions(+), 7 deletions(-) create mode 100644 dev/test_athena_terminal_mcp.py create mode 100644 platform/mcp/Dockerfile.athena-terminal create mode 100644 platform/mcp/athena_terminal_mcp.py diff --git a/dev/test_athena_terminal_mcp.py b/dev/test_athena_terminal_mcp.py new file mode 100644 index 0000000..275eed4 --- /dev/null +++ b/dev/test_athena_terminal_mcp.py @@ -0,0 +1,97 @@ +#!/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) + + 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) diff --git a/dev/test_openwebui_filters.py b/dev/test_openwebui_filters.py index 3d4be13..62d4511 100644 --- a/dev/test_openwebui_filters.py +++ b/dev/test_openwebui_filters.py @@ -170,6 +170,23 @@ 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): + result = await self._select( + "Zeige mir im Athena-Terminal mit ls die Dateien des KI-Hosts." + ) + self.assertEqual( + result["tool_ids"], ["server:mcp:athena-terminal-local"] + ) + + async def test_mcp_build_from_github_gets_source_and_platform_context(self): + result = await self._select( + "Ich möchte hierfür einen MCP bauen: https://github.com/foo/bar" + ) + self.assertEqual( + result["tool_ids"], + ["server:mcp:github-local", "server:mcp:athena-platform"], + ) + async def test_github_and_explicit_web_are_bounded_to_two(self): result = await self._select( "Prüfe dieses GitHub Repository und suche zusätzlich im Netz nach Nutzerstimmen." diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index 07ecba1..e0aaec0 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -13,6 +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 | | 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 | diff --git a/docs/CURRENT_REFERENCE.md b/docs/CURRENT_REFERENCE.md index 7485b72..d87b206 100644 --- a/docs/CURRENT_REFERENCE.md +++ b/docs/CURRENT_REFERENCE.md @@ -158,6 +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 - Websuche - Home Assistant - Sonarr/Radarr @@ -186,6 +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 frühere allgemeine Shell-MCP und doppelte, schreibende Werkzeuge gehören nicht zum Sicherheitsziel und werden nicht ungeprüft wiederhergestellt. diff --git a/docs/DISASTER_RECOVERY.md b/docs/DISASTER_RECOVERY.md index bb6360d..5f06867 100644 --- a/docs/DISASTER_RECOVERY.md +++ b/docs/DISASTER_RECOVERY.md @@ -56,7 +56,8 @@ laufen, sondern alle fachlichen Funktionen geprüft wurden. - [ ] Medium ist die gespeicherte Standardauswahl - [ ] 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 und Athena korrekt + Assistant, ARR, Navidrome, Unraid read-only, Athena-Plattformwissen und + das begrenzte Athena-Terminal korrekt - [ ] normale Unterhaltung erhält kein MCP; MUA wird niemals automatisch gewählt - [ ] SearXNG und TinySearch gesund @@ -78,6 +79,8 @@ 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 - [ ] lokales Dokumentations-Overlay ist auch im privaten Git enthalten und der Recovery-Koffer wurde danach neu erzeugt @@ -107,7 +110,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 +- [ ] kein allgemeiner Shell-MCP im Standardprofil; Athena-Terminal besitzt nur + die dokumentierte Positivliste und zwei read-only Mounts - [ ] Schreibaktionen verlangen Vorschau und Approval Ticket - [ ] Secret-Restore wurde ohne Klartextausgabe durchgeführt - [ ] verschlüsseltes Recovery-Bundle liegt außerhalb von Athena diff --git a/docs/PLATFORM_OVERVIEW.md b/docs/PLATFORM_OVERVIEW.md index aa5c51c..12cf300 100644 --- a/docs/PLATFORM_OVERVIEW.md +++ b/docs/PLATFORM_OVERVIEW.md @@ -96,6 +96,12 @@ 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. + ## Verbindliche Quellen 1. aktuell mit einem zuständigen Werkzeug gemessener Laufzeitzustand diff --git a/docs/QWEN_OPERATOR_CONTEXT.md b/docs/QWEN_OPERATOR_CONTEXT.md index 84b2880..c700743 100644 --- a/docs/QWEN_OPERATOR_CONTEXT.md +++ b/docs/QWEN_OPERATOR_CONTEXT.md @@ -226,6 +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 | | 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 | @@ -234,6 +235,13 @@ 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. + 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: diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 3667d2a..0daa80a 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -54,7 +54,11 @@ 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 -Dateisystemsuche gehören nicht ins Standardprofil. +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. ## Schreibaktionen diff --git a/platform/checks/verify-platform.sh b/platform/checks/verify-platform.sh index a379195..7407e1e 100755 --- a/platform/checks/verify-platform.sh +++ b/platform/checks/verify-platform.sh @@ -64,7 +64,8 @@ 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-unraid-official; do + mike-ai-mcp-github mike-ai-mcp-platform-context mike-ai-mcp-athena-terminal \ + mike-ai-mcp-unraid-official; do if container_healthy "$optional"; then pass "$optional aktiv" else diff --git a/platform/mcp/Dockerfile.athena-terminal b/platform/mcp/Dockerfile.athena-terminal new file mode 100644 index 0000000..db85734 --- /dev/null +++ b/platform/mcp/Dockerfile.athena-terminal @@ -0,0 +1,20 @@ +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"] diff --git a/platform/mcp/README.md b/platform/mcp/README.md index edf4a11..0233751 100644 --- a/platform/mcp/README.md +++ b/platform/mcp/README.md @@ -11,6 +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-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` | @@ -32,6 +33,15 @@ 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. + TinySearch bleibt als Ganzes read-only. Nur das flüchtige tmpfs-Verzeichnis `/home/tinysearch/.crawl4ai` ist beschreibbar, weil Crawl4AI dort seinen temporären Browser- und Sitzungszustand erzeugt. Es wird bei jedem @@ -67,6 +77,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 | | Ausdrücklich benötigte MUA-Verwaltungsaktion | MUA | Unraid-Diagnose nicht parallel | Ein leeres Ergebnis ist kein Grund, dieselbe Frage über mehrere unpassende @@ -83,7 +94,8 @@ oder ein anderes Werkzeug benötigt wird. - Jeder Container ist read-only, verliert Linux-Capabilities und hat `no-new-privileges`. - Der SSH-basierte Unraid-Container ist nicht Teil des Standardstarts. -- Ein allgemeiner Host-Shell-MCP wird bewusst nicht angeboten. +- Ein allgemeiner Host-Shell-MCP wird weiterhin bewusst nicht angeboten. Der + Athena Terminal MCP ist eine eng begrenzte Lesefassade, kein Host-Terminal. ## Start diff --git a/platform/mcp/athena_terminal_mcp.py b/platform/mcp/athena_terminal_mcp.py new file mode 100644 index 0000000..71c53a2 --- /dev/null +++ b/platform/mcp/athena_terminal_mcp.py @@ -0,0 +1,545 @@ +#!/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": + allowed = {"-c", "-E", "-F", "-i", "-l", "-n", "-r", "-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() diff --git a/platform/mcp/compose.yaml b/platform/mcp/compose.yaml index 55f86fd..3bef50c 100644 --- a/platform/mcp/compose.yaml +++ b/platform/mcp/compose.yaml @@ -166,6 +166,30 @@ services: retries: 5 start_period: 10s + mcp-athena-terminal: + <<: *tool-common + build: + context: . + dockerfile: Dockerfile.athena-terminal + image: mike-ai/mcp-athena-terminal:1.0.0 + container_name: mike-ai-mcp-athena-terminal + environment: + ATHENA_TERMINAL_WORKSPACE: /workspace + ATHENA_TERMINAL_RUNTIME: /runtime + 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 + networks: [tools] + healthcheck: + test: ["CMD", "python", "-c", "import socket; s=socket.create_connection(('127.0.0.1',8000),2); s.close()"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 10s + mcp-github: <<: *tool-common build: diff --git a/platform/openwebui/filters/auto_tool_selector.py b/platform/openwebui/filters/auto_tool_selector.py index b14f81c..61b13dc 100644 --- a/platform/openwebui/filters/auto_tool_selector.py +++ b/platform/openwebui/filters/auto_tool_selector.py @@ -1,7 +1,7 @@ """ title: MikeAI Auto Tool Selector author: MikeAI -version: 1.0.0 +version: 1.1.0 description: Selects a small, relevant set of MCP servers for each user request. """ @@ -27,6 +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", } LABELS = { @@ -37,6 +38,7 @@ class Filter: "unraid": "Unraid-Diagnose", "navidrome": "Navidrome", "platform": "Athena-Plattformwissen", + "terminal": "Athena-Terminal (begrenzt, nur lesend)", } def __init__(self): @@ -94,6 +96,15 @@ class Filter: selected: list[str] = [] + terminal = 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", + ), + ) platform = self._matches( text, ( @@ -103,6 +114,8 @@ 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( @@ -155,7 +168,9 @@ 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 platform: + if terminal: + selected.append("terminal") + elif platform: selected.append("platform") elif homeassistant: selected.append("homeassistant") diff --git a/platform/openwebui/install-filters.sh b/platform/openwebui/install-filters.sh index 55ccc55..c7ae652 100755 --- a/platform/openwebui/install-filters.sh +++ b/platform/openwebui/install-filters.sh @@ -212,6 +212,13 @@ 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.", + ), } changed = False for connection in connections: @@ -237,6 +244,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-unraid-official" in url: match = "unraid-readonly-local" else: @@ -274,6 +283,34 @@ with con: } ) changed = True + if not any( + isinstance(connection, dict) + and ( + str(connection.get("url", "")).lower() + == "http://mike-ai-mcp-athena-terminal:8000/mcp" + or str((connection.get("info") or {}).get("id", "")).lower() + == "athena-terminal-local" + ) + for connection in connections + ): + name, description = descriptions["athena-terminal-local"] + connections.append( + { + "url": "http://mike-ai-mcp-athena-terminal:8000/mcp", + "path": "", + "type": "mcp", + "auth_type": "none", + "headers": None, + "key": "", + "config": {"enable": True, "access_grants": []}, + "info": { + "id": "athena-terminal-local", + "name": name, + "description": description, + }, + } + ) + changed = True if github_enabled and not any( isinstance(connection, dict) and (