Add bounded read-only Athena terminal MCP
This commit is contained in:
@@ -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)
|
||||
@@ -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."
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
+5
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user