Add bounded read-only Athena terminal MCP

This commit is contained in:
Mikei386
2026-08-23 19:38:57 +02:00
parent c3e44b18f8
commit 91d36a98e3
15 changed files with 806 additions and 7 deletions
+97
View File
@@ -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)
+17
View File
@@ -170,6 +170,23 @@ class AutoToolSelectorTests(unittest.IsolatedAsyncioTestCase):
) )
self.assertEqual(result["tool_ids"], ["server:mcp:athena-platform"]) 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): async def test_github_and_explicit_web_are_bounded_to_two(self):
result = await self._select( result = await self._select(
"Prüfe dieses GitHub Repository und suche zusätzlich im Netz nach Nutzerstimmen." "Prüfe dieses GitHub Repository und suche zusätzlich im Netz nach Nutzerstimmen."
+1
View File
@@ -13,6 +13,7 @@
| Navidrome-MCP | Blakeem/Navidrome-MCP 2.2.0, Image per OCI-Digest | eigener optionaler Container ohne mpv | optional | | 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 | | 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 | | 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 | | 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 | | Unraid-MCP | lokales `runraid`-Binary | eigener optionaler Container | optional |
| Whisper | ggml-org/whisper.cpp | Service im Router-Deploy | optional | | Whisper | ggml-org/whisper.cpp | Service im Router-Deploy | optional |
+8
View File
@@ -158,6 +158,7 @@ Der isolierte Eignungs- und Ausfalltest ist in
Aktuell existieren funktionale Adapter für: Aktuell existieren funktionale Adapter für:
- Athena-Plattformwissen, Laufzeitsnapshot und kontrollierte Docs-Pflege - Athena-Plattformwissen, Laufzeitsnapshot und kontrollierte Docs-Pflege
- Athena Terminal: eng begrenzte Lesebefehle und Syntaxprüfung in Stack/Snapshot
- Websuche - Websuche
- Home Assistant - Home Assistant
- Sonarr/Radarr - 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 Der Schreibpfad ist auf `docs/*.md`, Vorschau, ausdrückliche Freigabe, atomare
Sicherung und sichtbare Git-/Recovery-Nacharbeit begrenzt. 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 Der frühere allgemeine Shell-MCP und doppelte, schreibende Werkzeuge gehören
nicht zum Sicherheitsziel und werden nicht ungeprüft wiederhergestellt. nicht zum Sicherheitsziel und werden nicht ungeprüft wiederhergestellt.
+6 -2
View File
@@ -56,7 +56,8 @@ laufen, sondern alle fachlichen Funktionen geprüft wurden.
- [ ] Medium ist die gespeicherte Standardauswahl - [ ] Medium ist die gespeicherte Standardauswahl
- [ ] Filter und Quick Actions sind allen fünf Presets zugeordnet - [ ] Filter und Quick Actions sind allen fünf Presets zugeordnet
- [ ] Auto Tool Selector wählt bei harmlosen Testfragen Web, GitHub, Home - [ ] 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 - [ ] normale Unterhaltung erhält kein MCP; MUA wird niemals automatisch gewählt
- [ ] SearXNG und TinySearch gesund - [ ] SearXNG und TinySearch gesund
@@ -78,6 +79,8 @@ laufen, sondern alle fachlichen Funktionen geprüft wurden.
System-Prompt entsprechen dem wiederhergestellten Stand System-Prompt entsprechen dem wiederhergestellten Stand
- [ ] Platform-Context-Snapshot aktuell; offene Vorschläge und angewandte - [ ] Platform-Context-Snapshot aktuell; offene Vorschläge und angewandte
Dokumentationsänderungen mit `athena_get_maintenance_status` geprüft 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 - [ ] lokales Dokumentations-Overlay ist auch im privaten Git enthalten und
der Recovery-Koffer wurde danach neu erzeugt 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 - [ ] Router nur aus erlaubtem Netz erreichbar
- [ ] Dienste laufen mit minimalen Rechten - [ ] Dienste laufen mit minimalen Rechten
- [ ] Environment-Dateien Modus 0600 - [ ] 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 - [ ] Schreibaktionen verlangen Vorschau und Approval Ticket
- [ ] Secret-Restore wurde ohne Klartextausgabe durchgeführt - [ ] Secret-Restore wurde ohne Klartextausgabe durchgeführt
- [ ] verschlüsseltes Recovery-Bundle liegt außerhalb von Athena - [ ] verschlüsseltes Recovery-Bundle liegt außerhalb von Athena
+6
View File
@@ -96,6 +96,12 @@ Der offizielle GitHub-MCP bietet nur vier Werkzeuge:
Andere GitHub-Werkzeuge sowie Schreibzugriffe sind serverseitig deaktiviert. 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 ## Verbindliche Quellen
1. aktuell mit einem zuständigen Werkzeug gemessener Laufzeitzustand 1. aktuell mit einem zuständigen Werkzeug gemessener Laufzeitzustand
+8
View File
@@ -226,6 +226,7 @@ Netzzugriff. Kein MCP-Port wird am Host veröffentlicht.
| Bereich | Aufgabe | Rechte | | Bereich | Aufgabe | Rechte |
|---|---|---| |---|---|---|
| Athena-Plattform | Architektur, Quellen, Laufzeitsnapshot, Dokumentationspflege | Lesen; Markdown nur Preview/Approval | | 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 | | Web | aktuelle öffentliche Recherche über SearXNG/TinySearch | read-only |
| GitHub | Repositorysuche, Baum, Dateiinhalt, Code-Suche | strikt read-only, vier Tools | | 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 | | 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 | | Unraid | Host-, Docker-, Array-, Netzwerk- und Logdiagnose | read-only Standard |
| MUA/Admin | eng definierte Unraid-Verwaltung | bewusst aktivieren | | 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 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: reinen stdio-zu-Streamable-HTTP-Brücke. Aktiv sind ausschließlich:
+5 -1
View File
@@ -54,7 +54,11 @@ Sandbox. Er ist klein, testbar und nicht von Clients direkt erreichbar.
| Administration | Vorschau, Approval-Ticket, Verifikation | | Administration | Vorschau, Approval-Ticket, Verifikation |
Allgemeine Shell, beliebiges SSH/SCP, freies `curl`, Docker-Administration und 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 ## Schreibaktionen
+2 -1
View File
@@ -64,7 +64,8 @@ else
fi fi
for optional in mike-ai-mcp-web mike-ai-mcp-homeassistant mike-ai-mcp-arr \ 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 if container_healthy "$optional"; then
pass "$optional aktiv" pass "$optional aktiv"
else else
+20
View File
@@ -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"]
+13 -1
View File
@@ -11,6 +11,7 @@ Prompts heraus, verhindert den früher beobachteten Kontextverbrauch von über
| Container | Endpunkt im Netz `mike-ai-tools` | Zweck | Standard | | 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-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-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-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` | | `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: Preview/Approval-Ablauf begrenzt. Vollständige Beschreibung:
[`docs/PLATFORM_CONTEXT_MCP.md`](../../docs/PLATFORM_CONTEXT_MCP.md). [`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 TinySearch bleibt als Ganzes read-only. Nur das flüchtige tmpfs-Verzeichnis
`/home/tinysearch/.crawl4ai` ist beschreibbar, weil Crawl4AI dort seinen `/home/tinysearch/.crawl4ai` ist beschreibbar, weil Crawl4AI dort seinen
temporären Browser- und Sitzungszustand erzeugt. Es wird bei jedem 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 | | Serien, Filme, fehlende Episoden und Indexer-Releases | Sonarr und Radarr | Web |
| Persönliche Musikbibliothek, Titel, Alben, Künstler und Playlists | Navidrome | Web, ARR | | Persönliche Musikbibliothek, Titel, Alben, Künstler und Playlists | Navidrome | Web, ARR |
| Lesende NAS-, Docker-, Array-, Netzwerk- und Logdiagnose | Unraid (Systemdiagnose) | MUA | | 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 | | Ausdrücklich benötigte MUA-Verwaltungsaktion | MUA | Unraid-Diagnose nicht parallel |
Ein leeres Ergebnis ist kein Grund, dieselbe Frage über mehrere unpassende 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 - Jeder Container ist read-only, verliert Linux-Capabilities und hat
`no-new-privileges`. `no-new-privileges`.
- Der SSH-basierte Unraid-Container ist nicht Teil des Standardstarts. - 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 ## Start
+545
View File
@@ -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()
+24
View File
@@ -166,6 +166,30 @@ services:
retries: 5 retries: 5
start_period: 10s 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: mcp-github:
<<: *tool-common <<: *tool-common
build: build:
@@ -1,7 +1,7 @@
""" """
title: MikeAI Auto Tool Selector title: MikeAI Auto Tool Selector
author: MikeAI author: MikeAI
version: 1.0.0 version: 1.1.0
description: Selects a small, relevant set of MCP servers for each user request. 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", "unraid": "server:mcp:unraid-readonly-local",
"navidrome": "server:mcp:navidrome-local", "navidrome": "server:mcp:navidrome-local",
"platform": "server:mcp:athena-platform", "platform": "server:mcp:athena-platform",
"terminal": "server:mcp:athena-terminal-local",
} }
LABELS = { LABELS = {
@@ -37,6 +38,7 @@ class Filter:
"unraid": "Unraid-Diagnose", "unraid": "Unraid-Diagnose",
"navidrome": "Navidrome", "navidrome": "Navidrome",
"platform": "Athena-Plattformwissen", "platform": "Athena-Plattformwissen",
"terminal": "Athena-Terminal (begrenzt, nur lesend)",
} }
def __init__(self): def __init__(self):
@@ -94,6 +96,15 @@ class Filter:
selected: list[str] = [] 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( platform = self._matches(
text, text,
( (
@@ -103,6 +114,8 @@ class Filter:
r"\b(?:disaster|bare metal)[- ]recovery\b", r"\b(?:disaster|bare metal)[- ]recovery\b",
r"\b(?:installations?|reinstall|setup)[- ]skript\b", r"\b(?:installations?|reinstall|setup)[- ]skript\b",
r"\bplattform(?:wissen|dokumentation)?\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( homeassistant = self._matches(
@@ -155,7 +168,9 @@ class Filter:
# A specialist source is more precise than public web search. Platform # A specialist source is more precise than public web search. Platform
# wins over a generic Docker mention when Athena is explicitly named. # wins over a generic Docker mention when Athena is explicitly named.
if platform: if terminal:
selected.append("terminal")
elif platform:
selected.append("platform") selected.append("platform")
elif homeassistant: elif homeassistant:
selected.append("homeassistant") selected.append("homeassistant")
+37
View File
@@ -212,6 +212,13 @@ with con:
"Quellcode dieses Werkzeug statt allgemeiner Websuche verwenden. Keine Issues, " "Quellcode dieses Werkzeug statt allgemeiner Websuche verwenden. Keine Issues, "
"Pull Requests, Actions oder Schreibzugriffe.", "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 changed = False
for connection in connections: for connection in connections:
@@ -237,6 +244,8 @@ with con:
match = "navidrome-local" match = "navidrome-local"
elif "mike-ai-mcp-github" in url: elif "mike-ai-mcp-github" in url:
match = "github-local" match = "github-local"
elif "mike-ai-mcp-athena-terminal" in url:
match = "athena-terminal-local"
elif "mike-ai-mcp-unraid-official" in url: elif "mike-ai-mcp-unraid-official" in url:
match = "unraid-readonly-local" match = "unraid-readonly-local"
else: else:
@@ -274,6 +283,34 @@ with con:
} }
) )
changed = True 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( if github_enabled and not any(
isinstance(connection, dict) isinstance(connection, dict)
and ( and (