munin/README.md
2026-09-15 20:36:26 +02:00

484 lines
17 KiB
Markdown

# 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.