# Munin Ein projektbezogenes Gedächtnis für KI-Agenten und Menschen. > In der nordischen Überlieferung sind Hugin und Munin die Raben Odins. Munin > steht für das Gedächtnis: Er fliegt aus, sammelt und kehrt zurück, um zu > berichten — was hier die Agenten tun. Passend zum Torwächter **Heimdall**, > unserem AI-Gateway. KI-Werkzeuge (Claude, Cline, OpenCode, eigene Anwendungen) legen darin über **MCP** ab, was sie lernen, und rufen es wieder auf. Menschen pflegen dasselbe Wissen über eine schlanke Weboberfläche. Beides landet im selben Speicher — der Unterschied ist ein Feld am Eintrag, kein zweites System. ``` KI-Werkzeug ──MCP──┐ ├──▶ Projekt ──▶ Einträge (SQLite) Browser ──HTTP─────┘ ``` --- ## Inhalt - [Schnellstart](#schnellstart) - [Grundbegriffe](#grundbegriffe) - [MCP einrichten](#mcp-einrichten) - [Die Werkzeuge](#die-werkzeuge) - [Weboberfläche](#weboberfläche) - [Konfiguration](#konfiguration) - [Sicherheit](#sicherheit) - [Betrieb](#betrieb) - [Entwicklung](#entwicklung) --- ## Schnellstart ```bash # 1. Konfiguration anlegen cp .env.example .env # 2. Geheimnisse erzeugen und eintragen openssl rand -hex 32 # → SESSION_SECRET openssl rand -hex 24 # → ADMIN_TOKEN # 3. Starten go run . ``` Dann `http://localhost:8080` öffnen und mit dem `ADMIN_TOKEN` anmelden. Der weitere Weg: 1. **Projekt anlegen** unter *Projekte* 2. **Zugang erzeugen** — der Key wird genau einmal angezeigt 3. **MCP-Client einrichten** (die Anleitung erscheint direkt neben dem neuen Key) --- ## Grundbegriffe | Begriff | Bedeutung | |---|---| | **Projekt** | Die Einheit der Abschottung. Eigene Einträge, eigene Zugänge, eigener MCP-Endpunkt. Projekte sehen sich gegenseitig nicht. | | **Eintrag** | Ein Stück Wissen: Bezeichner, Titel, Markdown-Inhalt, Schlagworte. Von Menschen oder Agenten geschrieben. | | **Bezeichner** (slug) | Kurzer, dauerhafter Name eines Eintrags (`deployment-prozess`). Der Schlüssel gegen Dubletten: Zweimal derselbe Bezeichner aktualisiert, statt eine zweite Fassung danebenzustellen. | | **API-Key** | Zugang zu genau einem Projekt, mit Namen und Rolle. Wird nur als Prüfsumme gespeichert. | | **Plattform-Admin** | Der `ADMIN_TOKEN`. Legt Projekte an, kommt an jedes Projekt. | ### Warum Bezeichner statt Ordner Ein Wiki gliedert nach Ordnern. Für ein Gedächtnis, in das Agenten schreiben, funktioniert das nicht: Wo eine Erkenntnis „hingehört", ist selten eindeutig, und ein Agent trifft diese Entscheidung schlecht. Stattdessen gibt es **Schlagworte** zum Einordnen und **Verweise** zum Verknüpfen. Im Inhalt zeigt `[[bezeichner]]` auf einen anderen Eintrag; die Gegenrichtung („was verweist hierher") entsteht daraus von selbst und steht in der Detailansicht wie auch in der MCP-Antwort. --- ## MCP einrichten Nach dem Erzeugen eines Keys zeigt die Weboberfläche den passenden Abschnitt an. Der Key bestimmt das Projekt — ein zusätzlicher Parameter ist nicht nötig. Wer mit mehreren Projekten arbeitet, trägt mehrere Server mit je eigenem Key ein. Alle Clients sprechen denselben Endpunkt `…/mcp` über Streamable HTTP an. Die Konfigurationsdateien sehen trotzdem unterschiedlich aus: | Client | Datei | Schlüssel | Transport heißt dort | |---|---|---|---| | Claude Code | `.mcp.json` / `~/.claude.json` | `mcpServers` | `"type": "http"` | | Cline | `cline_mcp_settings.json` / `~/.cline/mcp.json` | `mcpServers` | `"type": "streamableHttp"` | | OpenCode | `opencode.json` | `mcp` | `"type": "remote"` | Die beiden ersten Zeilen sind die Falle: gleicher Schlüssel, anderer Wert für `type`. Ein aus Claude Code kopierter Block läuft in Cline nicht. ### Claude Code Am schnellsten über die Kommandozeile: ```bash claude mcp add --transport http munin-plattform https://munin.intern/mcp \ --header "Authorization: Bearer wk_…" ``` Ohne `--scope` landet der Eintrag in `~/.claude.json` und gilt nur im aktuellen Projekt; `--scope user` macht ihn überall verfügbar. Fürs Team gehört er stattdessen als `.mcp.json` ins Repository — dann aber **ohne** den Key im Klartext. Claude Code löst `${VAR}` aus der Umgebung auf: ```json { "mcpServers": { "munin-plattform": { "type": "http", "url": "https://munin.intern/mcp", "headers": { "Authorization": "Bearer ${MUNIN_KEY}" } } } } ``` Jede Person setzt `MUNIN_KEY` auf ihren eigenen Key. Damit steht im Repository die Einrichtung, nicht das Geheimnis — und im Protokoll steht trotzdem, wer geschrieben hat. ### Cline Über die Oberfläche: *MCP Servers* → *Configure* → *Configure MCP Servers*. In der Kommandozeilenfassung liegt dieselbe Datei unter `~/.cline/mcp.json`. ```json { "mcpServers": { "munin-plattform": { "type": "streamableHttp", "url": "https://munin.intern/mcp", "headers": { "Authorization": "Bearer wk_…" }, "disabled": false, "autoApprove": ["recall", "get", "list"] } } } ``` `autoApprove` nennt die Werkzeuge, die ohne Rückfrage laufen dürfen. Die drei lesenden gehören dort hinein — sonst muss jede Suche einzeln bestätigt werden, und ein Agent, der für jeden Blick ins Gedächtnis nachfragen muss, benutzt es nicht. `remember` und `forget` gehören ausdrücklich **nicht** hinein: Sie verändern gemeinsames Wissen. ### OpenCode `opencode.json` im Projektverzeichnis oder global unter `~/.config/opencode/opencode.json`. Anderes Schema als bei den beiden anderen — der Block heißt `mcp`, nicht `mcpServers`: ```json { "$schema": "https://opencode.ai/config.json", "mcp": { "munin-plattform": { "type": "remote", "url": "https://munin.intern/mcp", "enabled": true, "headers": { "Authorization": "Bearer {env:MUNIN_KEY}" } } } } ``` OpenCode ersetzt `{env:VAR}` aus der Umgebung und `{file:~/pfad}` durch den Inhalt einer Datei. Ein nicht gesetztes `{env:…}` wird zur leeren Zeichenkette — der Server antwortet dann mit `401`, nicht mit einer Fehlermeldung über die Konfiguration. ### Danach **Ein Key je Person und je Agent.** Nur so steht im Protokoll, wer etwas geschrieben hat, und ein einzelner Zugang lässt sich zurückziehen, ohne alle anderen zu stören. Verbindung prüfen: Der Agent sollte `recall`, `get`, `list`, `remember` und `forget` anbieten. Tut er das nicht, hilft ein Blick ins Zugriffsprotokoll des Servers — ein `401` dort heißt falscher oder zurückgezogener Key, gar kein Eintrag heißt, dass der Client den Server nie aufgerufen hat. Wie man Agenten so anspricht, dass sie den Speicher auch nutzen: [AGENTEN-GUIDE.md](AGENTEN-GUIDE.md). --- ## Die Werkzeuge | Werkzeug | Rolle | Zweck | |---|---|---| | `recall` | read | Durchsucht den Speicher. Der Haupteinstieg — auch vor dem Schreiben. | | `get` | read | Lädt einen Eintrag vollständig, samt Verweisen und Fassungsnummer. | | `list` | read | Überblick: zuletzt geändert, alle Schlagworte. | | `remember` | write | Legt einen Eintrag an oder aktualisiert ihn. | | `forget` | write | Löscht einen Eintrag. | Der Server schickt jedem Client eine **Anleitung** (`instructions`) mit, die das Vorgehen beschreibt — vor allem: *vor* dem Schreiben erst `recall` aufrufen. Das ist das wirksamste Mittel gegen Dubletten und steht in `internal/mcpserver/server.go`. ### Schreibbudget `remember` und `forget` sind je Projekt auf **1.000 Aufrufe pro Stunde** gedeckelt (`WRITE_LIMIT_HOURLY`). Lesen ist nie begrenzt. Der Grund ist die Kehrseite einer anderen Entscheidung: Ein gültiger Key umgeht die IP-Sperre bewusst, weil Deploy-Skript, MCP-Client und Browser eines Büros sich nach außen oft eine Adresse teilen. Damit hätte ein Agent, der in einer Schleife hängt, sonst überhaupt keine Grenze. Das Budget hängt am **Projekt**, nicht am Key: Ein Limit je Key ließe sich umgehen, indem man einen zweiten Key anlegt, und das darf jeder Projekt-Admin selbst. Einzelne Projekte bekommen über *Projekte → Bearbeiten* eine eigene Obergrenze; die Übersicht zeigt, wie viel im laufenden Fenster noch frei ist. Ändern darf sie nur der Plattform-Admin — aus demselben Grund. Ist das Budget erschöpft, bekommt das Modell keinen nackten Fehler, sondern die Anweisung, abzubrechen und das Limit zu melden — sonst versucht es sofort wieder oder weicht auf ein anderes Werkzeug aus. ### Gleichzeitiges Schreiben `get` liefert eine `version`. Wird sie bei `remember` als `expected_version` mitgegeben, meldet der Server einen Konflikt, falls jemand anderes den Eintrag inzwischen geändert hat — statt die fremde Änderung stillschweigend zu überschreiben. ``` Agent A: get(deployment) → version 3 Agent B: get(deployment) → version 3 Agent A: remember(…, expected_version=3) → OK, jetzt version 4 Agent B: remember(…, expected_version=3) → Konflikt: neu laden, zusammenführen ``` --- ## Weboberfläche | Weg | Zweck | |---|---| | `/login` | Anmeldung mit API-Key oder Admin-Token | | `/admin/projects` | Projekte anlegen und auflisten (nur Plattform-Admin) | | `/admin/projects/{projekt}/edit` | Name, Beschreibung und Schreibbudget ändern; Projekt löschen | | `/p/{projekt}/` | Einträge durchsuchen, nach Schlagwort filtern | | `/p/{projekt}/e/{bezeichner}` | Eintrag lesen, mit Rückverweisen | | `/p/{projekt}/keys` | Zugänge verwalten (Rolle `admin`) | | `/p/{projekt}/audit` | Protokoll des Projekts | | `/healthz` | Healthcheck, ohne Anmeldung | ### Rollen | Rolle | Darf | |---|---| | `read` | lesen, suchen | | `write` | zusätzlich schreiben und löschen | | `admin` | zusätzlich Zugänge des eigenen Projekts verwalten | --- ## Konfiguration | Variable | Standard | Bedeutung | |---|---|---| | `SESSION_SECRET` | — | **Pflicht.** Signiert Sitzungen und CSRF-Tokens. Mindestens 32 Zeichen. | | `ADMIN_TOKEN` | — | **Pflicht.** Zugang des Plattform-Admins. Mindestens 16 Zeichen. | | `DB_PATH` | `data/munin.db` | Pfad zur SQLite-Datenbank | | `PORT` | `8080` | Port des Servers | | `COOKIE_SECURE` | automatisch | `Secure`-Flag der Cookies. Ohne Angabe: an, außer bei localhost. | | `ENABLE_HSTS` | `false` | HSTS-Header senden. Nur bei gültigem Zertifikat. | | `TRUSTED_PROXY` | — | IPs/CIDR-Netze, deren `X-Forwarded-For` geglaubt wird | | `MAX_API_BODY_BYTES` | `4194304` | Höchstgröße eines MCP-Requests (4 MB) | | `WRITE_LIMIT_HOURLY` | `1000` | Schreibende MCP-Aufrufe je Projekt und Stunde. `0` = unbegrenzt. | | `EXPORT_DIR` | — | Zielverzeichnis des Markdown-Exports. Leer = abgeschaltet. | Fehlen die Pflichtangaben oder sind sie zu kurz, startet der Server nicht. Ein schwaches Geheimnis wäre die Wurzel für alles andere — ein klarer Abbruch ist besser als unsicherer Betrieb. --- ## Sicherheit ### Was geprüft wird | Schutz | Wo | |---|---| | Signierte Sitzungs-Cookies (HMAC-SHA256) | `internal/auth/session.go` | | API-Keys nur als SHA-256-Prüfsumme gespeichert | `internal/auth/keys.go` | | Zurückgezogener Key beendet laufende Sitzungen sofort | `internal/web/middleware.go` | | CSRF-Token in allen Formularen | `internal/auth/csrf.go` | | `SameSite=Lax`, `HttpOnly`, `Secure` | `internal/auth/cookies.go` | | Rate-Limiting je Adresse, nur auf Fehlversuche | `internal/auth/ratelimit.go` | | Content-Security-Policy und weitere Schutz-Header | `internal/web/middleware.go` | | Größenlimits für Request-Bodies | `internal/web/middleware.go` | | Server-Timeouts gegen Slowloris | `main.go` | | Optimistisches Sperren gegen stilles Überschreiben | `internal/store/entries.go` | ### Abschottung der Projekte Sie ist **strukturell**, nicht eine Frage der richtigen Filterbedingung: - Jedes Projekt hat einen **eigenen MCP-Server**, dessen Werkzeuge fest auf dieses Projekt verdrahtet sind - Jedes Projekt hat einen **eigenen Suchindex** — auch die BM25-Gewichtung wird nicht über Projektgrenzen hinweg berechnet - Jeder Datenbankzugriff ist auf die Projekt-ID eingeschränkt - Ein fremdes Projekt antwortet mit **404, nicht 403** — sonst ließe sich über die Statuscodes herausfinden, welche Projekte es sonst noch gibt ### Warum SHA-256 für Keys und nicht bcrypt Für Passwörter wären langsame Verfahren richtig: Menschen wählen aus einem kleinen, erratbaren Raum. Ein API-Key hier ist aber 256 Bit aus `crypto/rand` — diesen Raum durchsucht niemand. Die Langsamkeit brächte keinen Gewinn, würde aber jeden Request bremsen. Entscheidend ist der zweite Punkt: Weil SHA-256 schnell und deterministisch ist, wird der Key in **einem indizierten Lookup** aufgelöst statt in einer Schleife über alle Keys. ### Prompt Injection Der Inhalt des Speichers landet direkt im Kontext von KI-Werkzeugen. Wer in ein Projekt schreiben darf, schreibt damit indirekt in die Prompts aller anderen, die dieses Projekt nutzen. Dagegen hilft kein technischer Schutz, nur Nachvollziehbarkeit: Jeder Eintrag trägt Herkunft (`human`/`agent`) und den Namen des verwendeten Zugangs, und jede Änderung steht im Protokoll. ### Wo die Grenzen liegen - **Keine Benutzerkonten.** Zugänge sind Keys, keine Personen. Ein Key je Person ist der Ersatz — geplant ist später Keycloak. - **Projekte pflegt nur der Plattform-Admin.** Ein Team kann seinen eigenen Projektnamen nicht selbst korrigieren. Solange es wenige Projekte gibt, ist eine Berechtigungsstufe weniger den Umweg wert. - **Kein TLS im Server selbst.** Für HTTPS gehört ein Reverse-Proxy davor. - **Ein Workspace.** Die Struktur für mehrere ist im Schema angelegt (`workspace_id`), aber nicht in der Oberfläche. - **Das Schreibbudget zählt im Arbeitsspeicher.** Nach einem Neustart steht es wieder bei null. Als Netz gegen eine Schleife reicht das; eine verbindliche Zusage ist es nicht. --- ## Betrieb ### Docker ```bash docker build -t munin . docker run -d --name munin \ -p 8080:8080 \ -v munin-daten:/data \ -e SESSION_SECRET="$(openssl rand -hex 32)" \ -e ADMIN_TOKEN="$(openssl rand -hex 24)" \ -e ENABLE_HSTS=true \ -e TRUSTED_PROXY=172.16.0.0/12 \ munin ``` Die Geheimnisse sollten aus einem Passwortspeicher kommen, nicht bei jedem Start neu erzeugt werden — ein neues `SESSION_SECRET` macht alle Sitzungen ungültig. ### Hinter einem Reverse-Proxy ```bash TRUSTED_PROXY=127.0.0.1 # Adresse des Proxys COOKIE_SECURE=true # falls der Proxy TLS beendet ENABLE_HSTS=true # nur mit gültigem Zertifikat ``` Ohne `TRUSTED_PROXY` sieht der Server für alle Besucher dieselbe Adresse — das Rate-Limiting würde dann entweder niemanden bremsen oder beim ersten Angreifer gleich alle aussperren. ### Datensicherung **Datenbank.** Alles steckt in einer Datei. Weil SQLite im WAL-Modus läuft, sollte sie nicht im laufenden Betrieb kopiert werden: ```bash sqlite3 /pfad/munin.db ".backup '/sicherung/wissen-$(date +%F).db'" ``` **Markdown-Export.** Ist `EXPORT_DIR` gesetzt, schreibt der Server den Bestand täglich als Markdown-Dateien dorthin — ein Ordner je Projekt, eine Datei je Eintrag, dazu eine `_index.md` als Übersicht: ``` export/ plattform/ _index.md deployment.md server-zugang.md ``` Jede Datei trägt alle Felder im Kopf (Bezeichner, Schlagworte, Herkunft, Autor, Fassung, Zeitstempel), sodass sich der Bestand daraus auch wieder einlesen ließe. Das eignet sich als Git-Sicherung: Ein Lauf ohne inhaltliche Änderungen fasst keine Datei an und erzeugt deshalb auch keinen Diff. ```bash # Auf Knopfdruck: "Jetzt exportieren" unter /admin/projects # Für Cron oder ein Sicherungsskript: ./munin -export ``` Der Export ist eine Sicherung, keine zweite Wahrheit — Änderungen an den Dateien wirken nicht zurück und gehen beim nächsten Lauf verloren. ### Protokoll Vorgänge werden 90 Tage aufbewahrt und danach automatisch gelöscht (`auditRetention` in `main.go`). Das Protokoll enthält Adressen und Zugangsnamen, aus denen sich Personen ableiten lassen — eine Aufbewahrungsfrist ist deshalb die Voraussetzung dafür, es überhaupt führen zu dürfen. --- ## Entwicklung ```bash go run . # starten go test ./... # alle Tests go vet ./... # statische Prüfung gofmt -l . # Formatierung prüfen ``` ### Aufbau ``` main.go Konfiguration, Verdrahtung, Serverstart internal/ store/ SQLite: Schema, Migrationen, alle Abfragen entry/ Fachlogik: Markdown, Verweise, Textausschnitte search/ BM25-Volltextindex, einer je Projekt auth/ Sitzungen, CSRF, Cookies, Keys, Rate-Limiting export/ Markdown-Export als Sicherung web/ Routen, Middleware, Handler mcpserver/ MCP-Werkzeuge templates/ static/ eingebettet ins Binary ``` Die Richtung der Abhängigkeiten zeigt immer nach innen: `web` und `mcpserver` kennen `store`, aber `store` kennt keinen von beiden. ### Datenbank SQLite über `modernc.org/sqlite` — ein in Go übersetzter Nachbau, kein C. Nur deshalb lässt sich das Programm als statisches Binary ohne Laufzeitumgebung ausliefern. Das SQL vermeidet bewusst alles, was nur SQLite kann (kein `AUTOINCREMENT`, keine Datumsfunktionen, keine `INSERT OR REPLACE`), damit ein Umstieg auf PostgreSQL offenbleibt. Die Regeln stehen als Kommentar in `internal/store/migrations/001_init.sql`. **Schema ändern:** eine neue Datei `003_….sql` in `migrations/` anlegen. Sie wird beim nächsten Start automatisch angewandt und vermerkt; bestehende Migrationen werden nie verändert. --- ## Was noch aussteht [AUSBAUSTUFEN.md](AUSBAUSTUFEN.md) hält fest, was bewusst nicht gebaut wurde — mit Messwerten, Begründung und den Auslösern, an denen man merkt, dass es Zeit dafür ist. Unter anderem: FTS5 statt eigenem Suchindex, eine Datenbank je Projekt, semantische Suche.