diff --git a/deploy/opencode-plugin/README.md b/deploy/opencode-plugin/README.md new file mode 100644 index 0000000..211a020 --- /dev/null +++ b/deploy/opencode-plugin/README.md @@ -0,0 +1,78 @@ +# OpenCode-Plugin: MC2-Governor + +**Hier liegt die Quelle. Der Einsatzort ist eine Kopie.** + +``` +deploy/opencode-plugin/mc2-governor.ts <- versioniert, hier aendern + | kopieren + v +%USERPROFILE%\.config\opencode\plugin\ <- wird von OpenCode automatisch geladen +``` + +Bis zum 21.08.2026 existierte diese Datei **nur** im Konfigordner des PCs — 11 KB +verhaltensbestimmender Code ohne Sicherung und ohne Historie. Deshalb liegt sie jetzt hier. + +## Ausbringen + +```powershell +Copy-Item "deploy\opencode-plugin\mc2-governor.ts" "$env:USERPROFILE\.config\opencode\plugin\" -Force +``` + +Wirkt sofort in der naechsten OpenCode-Sitzung; OpenChamber muss dafuer nicht neu starten, +eine neue Sitzung reicht. + +## Was es tut + +1. **Werkzeug-Zaun** (`tool.execute.before`) — blockt Handgriffe, die ein Agent nie + unbeaufsichtigt tun darf. Leitplanke gegen Entgleisen, **keine** Sandbox. +2. **Pruef-Tor + Reparaturschleife** (`session.idle`) — sagt der Agent „fertig", laeuft der + Verify-Befehl aus der Datei `VERIFY` im Repo-Wurzelverzeichnis. ROT geht als naechster + Auftrag zurueck an den Agenten, bis zu `MC2_LOOP_MAX_ROUNDS` mal. + ‼️ **Schlaeft derzeit:** es gibt in keinem Projekt eine `VERIFY`-Datei. Ohne sie tut + diese Funktion nichts — schadet aber auch nicht. +3. **Savepoint statt Zusammenfassen** (`session.compacted`) — beim Komprimieren fallen + still die Regeln aus dem Kontext; stattdessen wird `SAVEPOINT.md` geschrieben. +4. **Stimme** — Ereignisse gehen an MC2 `/api/voice/announce`, Lucy spricht sie. + +Der zugehoerige **Governor-Proxy auf `:8100`** (Token-Zaehler mit Hart-Deckel, Juli 2026) +**existiert nicht mehr** — kein Port, kein Ordner, keine Unit. Nachgemessen am 21.08.2026. +Dieses Plugin haengt nicht an ihm. + +## Schalter (Umgebungsvariablen) + +| Variable | Default | Wirkung | +|---|---|---| +| `MC2_BOX_URL` | `http://192.168.178.151:9001` | MC2-Basis fuer Meldungen | +| `MC2_FENCE_OFF` | — | `1` schaltet den **ganzen** Zaun ab (alles oder nichts) | +| `MC2_LOOP_AUTOFIX` | an | `0` schaltet die Reparaturschleife ab | +| `MC2_LOOP_MAX_ROUNDS` | `3` | Runden der Reparaturschleife | +| `MC2_LOOP_ANNOUNCE` | an | `0` macht Lucy stumm | +| `MC2_LOOP_SILENT` | — | `1` = Meldungen ohne Ton | + +## Aenderung 21.08.2026: `git push` ist frei + +Der Zaun blockte urspruenglich **jedes** `git push` („Veroeffentlichen ist Sache des +Menschen"). Das passt nicht mehr: die Bahn ist jetzt PC → Gitea → Box, und jede Aenderung +soll auch im git landen. Ein Agent, der committen, aber nicht pushen darf, laesst Arbeit +auf einem einzelnen Rechner liegen. + +**Erzwungenes Pushen bleibt verboten** — `--force`, `--mirror`, `--delete`. Das +ueberschreibt fremde Arbeit auf dem Server und ist genau die Klasse Unfall, gegen die der +Zaun existiert. `--force-with-lease` ist bewusst ausgenommen. + +Beides gemessen: `git push --dry-run origin main` laeuft durch, +`git push --force --dry-run origin main` wird mit `[MC2-ZAUN]` abgewiesen. + +## ‼️ Falle: das `bash`-Werkzeug ist auf Windows kein bash + +Es laeuft in **PowerShell**. `tail`, `head`, `grep` und Freunde gibt es dort nicht, und +Pipes verhalten sich anders. Wer dem Agenten Befehle vorgibt, muss das beruecksichtigen. + +## ‼️ Falle: Gitea-Anmeldung ist flatterhaft + +Der Git Credential Manager haelt fuer die DDNS-Domain ein **OAuth-Token mit einer Stunde +Laufzeit**. Laeuft es ab, scheitert der Push des Agenten mit +`remote: Failed to authenticate user` — ein Push aus PowerShell erneuert es, danach geht es +wieder. Dauerhafte Loesung waere ein langlebiges Gitea-Token auf die **interne** Adresse +`http://192.168.178.153:3000` (die DDNS-Domain ist nachts durch die Zwangstrennung ohnehin +zeitweise tot). diff --git a/deploy/opencode-plugin/mc2-governor.ts b/deploy/opencode-plugin/mc2-governor.ts new file mode 100644 index 0000000..eea5e8a --- /dev/null +++ b/deploy/opencode-plugin/mc2-governor.ts @@ -0,0 +1,255 @@ +/** + * MC2-Governor — der "Fahrlehrer" im OpenCode-Agenten. + * + * Der Governor-Proxy (:8100) ist die Tankuhr: er sieht nur Tokens und zieht die + * Notbremse. Dieses Plugin sitzt IM Agenten und sieht alles andere — jeden + * Werkzeuggriff, jede Datei, jedes Sitzungsende. Es macht vier Dinge: + * + * 1. WERKZEUG-ZAUN (tool.execute.before) + * Blockt Handgriffe, die ein Agent nie unbeaufsichtigt tun darf: push, + * Historie umschreiben, rekursiv loeschen, sudo, Fremd-Hosts. Genau dieser + * Zustandsautomat-Zaun hob lokale Modelle in Messungen von 2/10 auf 10/10 — + * nicht weil sie schlauer werden, sondern weil sie nicht mehr entgleisen. + * + * 2. PRUEF-TOR + SCHLEIFE (session.idle) + * Sagt der Agent "fertig", laeuft der Verify-Befehl des Projekts (Datei + * `VERIFY` im Repo-Wurzelverzeichnis). GRUEN -> Meldung. ROT -> der Fehler + * geht als naechster Auftrag automatisch zurueck an den Agenten, bis zu + * MC2_LOOP_MAX_ROUNDS mal. Das ist die "Ralph-Schleife", nur mit Bremse. + * + * 3. SAVEPOINT STATT ZUSAMMENFASSEN (session.compacted) + * Beim Komprimieren fallen still die Regeln aus dem Kontext (Paper + * "Governance Decay"). Wir schieben stattdessen den Auftrag nach, SAVEPOINT.md + * zu schreiben — Wissen lebt in Datei + git, nicht im schrumpfenden Chat. + * + * 4. STIMME (MC2 /api/voice/announce) + * Jedes Ereignis geht mit eigenem Absender `loop` in MC2s Melde-Briefkasten. + * Lucy pollt ihn ohnehin und spricht ihn — ohne eine Zeile Lucy-Code. + * + * Schalter (Umgebungsvariablen): + * MC2_BOX_URL MC2-Basis (Default http://192.168.178.151:9001) + * MC2_LOOP_AUTOFIX Selbstreparatur (1 = an, Default an) + * MC2_LOOP_MAX_ROUNDS max. Reparaturrunden (Default 3) + * MC2_LOOP_SILENT 1 = Lucy schweigt (Nachtlauf; Meldungen kommen trotzdem an) + * MC2_LOOP_ANNOUNCE 0 = gar keine Meldungen + * MC2_FENCE_OFF 1 = Werkzeug-Zaun aus (nur fuer Notfaelle) + * + * Liegt global unter ~/.config/opencode/plugin/ und wirkt damit in JEDEM Projekt — + * am Tag in Zed, nachts im Cron. Ein Regelwerk, zwei Ausloeser. + */ + +const BOX_URL = process.env.MC2_BOX_URL || "http://192.168.178.151:9001" +const AUTOFIX = process.env.MC2_LOOP_AUTOFIX !== "0" +const MAX_ROUNDS = parseInt(process.env.MC2_LOOP_MAX_ROUNDS || "3", 10) +const SILENT = process.env.MC2_LOOP_SILENT === "1" +const ANNOUNCE_ON = process.env.MC2_LOOP_ANNOUNCE !== "0" +const FENCE_OFF = process.env.MC2_FENCE_OFF === "1" + +/** + * Verbotene Shell-Handgriffe. Bewusst als Muster auf der ROHEN Kommandozeile — + * ein Agent, der `git push` in ein `bash -c` verpackt, wird trotzdem erwischt. + * Kein Anspruch auf Sandbox-Sicherheit: das ist ein Leitplanken-Zaun gegen + * Entgleisen, keine Abwehr gegen einen boesartigen Akteur. + */ +const FENCE: Array<{ rx: RegExp; why: string }> = [ + // 21.08.2026: normales `git push` ist FREI. Der Weg ist jetzt PC -> Gitea -> Box, und + // jede Aenderung soll auch im git landen — ein Agent, der committen, aber nicht pushen + // darf, laesst Arbeit auf einem einzelnen Rechner liegen. Was schlecht ist, faengt + // hinterher die CI-Ampel; dieser Zaun muss es nicht vorher verhindern. + // Erzwungenes Pushen bleibt verboten: das ueberschreibt fremde Arbeit auf dem Server + // und ist genau die Klasse Unfall, gegen die der Zaun ueberhaupt existiert. + { rx: /\bgit\s+push\b[^\n]*(--force(?!-with-lease)|\s-f\b|--mirror\b|--delete\b)/, + why: "git push --force/--mirror/--delete — ueberschreibt fremde Arbeit auf dem Server. Normales `git push` ist erlaubt; muss wirklich erzwungen werden, sag es dem Menschen." }, + { rx: /\bgit\s+reset\s+--hard\b/, why: "git reset --hard — verwirft Arbeit unwiederbringlich." }, + { rx: /\bgit\s+clean\s+-[a-z]*f/, why: "git clean -f — loescht ungetrackte Dateien unwiederbringlich." }, + { rx: /\bgit\s+(rebase|filter-branch|reflog\s+expire)\b/, why: "Historie umschreiben ist tabu." }, + { rx: /\brm\s+-[a-zA-Z]*r[a-zA-Z]*f?\s+\/(?:\s|$)/, why: "rm -rf / — nein." }, + { rx: /\brm\s+-[a-zA-Z]*[rf]/, why: "rekursives/erzwungenes Loeschen — bitte gezielt loeschen statt pauschal." }, + { rx: /\bsudo\b/, why: "sudo — Rechteausweitung gehoert nicht in einen Agentenlauf." }, + { rx: /\b(shutdown|reboot|mkfs|dd\s+if=)/, why: "System-/Datentraeger-Eingriff." }, + { rx: /\b(curl|wget)\b[^|]*\|\s*(ba)?sh\b/, why: "Aus dem Netz laden und direkt ausfuehren — klassischer Fussschuss." }, + { rx: /\bssh\s+(?!arcane@192\.168\.178\.162|-o\s+StrictHostKeyChecking=no\s+arcane@)/, why: "ssh nur zur freigegebenen Arcane-VM." }, + { rx: /\bnpm\s+publish\b|\btwine\s+upload\b/, why: "Veroeffentlichen von Paketen ist Sache des Menschen." }, +] + +/** Zaehler je Sitzung: wie viele Selbstreparatur-Runden liefen schon? */ +const rounds = new Map() +/** Doppel-Feuern verhindern: session.idle kann mehrfach kommen. */ +const busy = new Set() + +async function announce(subject: string, text: string, priority: "normal" | "silent" = "normal") { + if (!ANNOUNCE_ON) return + try { + await fetch(`${BOX_URL}/api/voice/announce`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + subject, + text, + source: "loop", + priority: SILENT ? "silent" : priority, + }), + signal: AbortSignal.timeout(4000), + }) + } catch { + /* best effort — eine stumme Lucy darf den Bau nie aufhalten */ + } +} + +export const MC2Governor = async ({ client, $, directory, worktree }: any) => { + const root: string = worktree || directory || process.cwd() + + /** + * Verify-Befehl des Projekts lesen. Fehlt die Datei, ist das Pruef-Tor AUS. + * Bewusst ueber fs statt `cat`: das Plugin laeuft am Tag auf Windows (Zed) und + * nachts auf der Box — `cat` gibt es auf Windows nicht zuverlaessig. + */ + async function readVerify(): Promise { + try { + const { readFile } = await import("node:fs/promises") + const { join } = await import("node:path") + const raw = await readFile(join(root, "VERIFY"), "utf8") + const cmd = raw + .split("\n") + .map((l: string) => l.trim()) + .filter((l: string) => l && !l.startsWith("#")) + .join(" && ") + return cmd || null + } catch { + return null + } + } + + /** + * Verify ausfuehren. Rueckgabe: {ok, output} — Ausgabe auf das Wesentliche gekuerzt. + * `{ raw: cmd }` schiebt den Befehl UNESCAPED in Buns Shell; ein normales + * `${cmd}` wuerde die ganze Zeile als EIN Argument uebergeben und nie laufen. + * Buns Shell ist plattformunabhaengig — kein `bash -lc`, das auf Windows fehlt. + */ + async function runVerify(cmd: string): Promise<{ ok: boolean; out: string }> { + try { + const res = await $`${{ raw: cmd }}`.cwd(root).nothrow().quiet() + const out = `${res.stdout?.toString() ?? ""}${res.stderr?.toString() ?? ""}` + return { ok: res.exitCode === 0, out: out.slice(-4000) } + } catch (e: any) { + return { ok: false, out: String(e?.message ?? e).slice(-4000) } + } + } + + /** Dem laufenden Agenten einen neuen Auftrag schicken (Selbstreparatur-Schleife). */ + async function sendPrompt(sessionID: string, text: string): Promise { + try { + await client.session.prompt({ + path: { id: sessionID }, + body: { parts: [{ type: "text", text }] }, + }) + return true + } catch { + return false + } + } + + return { + // ── 1. Werkzeug-Zaun ─────────────────────────────────────────────────── + "tool.execute.before": async (input: any, output: any) => { + if (FENCE_OFF) return + if (input?.tool !== "bash") return + const cmd: string = output?.args?.command ?? "" + if (!cmd) return + for (const rule of FENCE) { + if (rule.rx.test(cmd)) { + await announce( + "[Zaun]", + `Ich habe einen Befehl geblockt: ${rule.why}`, + "silent", + ) + // Werfen = OpenCode bricht genau diesen Werkzeugaufruf ab und gibt dem + // Modell den Grund zurueck. Der Agent arbeitet weiter, nur anders. + throw new Error( + `[MC2-ZAUN] Blockiert: ${rule.why}\n` + + `Befehl war: ${cmd}\n` + + `Waehle einen anderen Weg. Wenn das wirklich noetig ist, sag es dem Menschen — ` + + `er macht es selbst.`, + ) + } + } + }, + + // ── 2.-4. Ereignisse ─────────────────────────────────────────────────── + event: async ({ event }: any) => { + const type: string = event?.type ?? "" + const props: any = event?.properties ?? event ?? {} + const sessionID: string = props.sessionID || props.sessionId || props.id || "" + + // ── Savepoint statt Zusammenfassen ────────────────────────────────── + if (type === "session.compacted" || type === "experimental.session.compacting") { + await announce( + "[Sitzung]", + "Die Sitzung wurde komprimiert — ich lasse den Stand in SAVEPOINT.md sichern.", + "silent", + ) + if (sessionID) { + await sendPrompt( + sessionID, + "[MC2-GOVERNOR] Der Kontext wurde gerade komprimiert — dabei gehen still " + + "Regeln und Details verloren. Aktualisiere JETZT SAVEPOINT.md: was wirklich " + + "erledigt ist (nur was im Code steht), der genaue naechste Schritt, offene " + + "Fragen, Stolpersteine. Committe die Datei. Danach arbeite normal weiter.", + ) + } + return + } + + // ── Pruef-Tor + Selbstreparatur ───────────────────────────────────── + if (type !== "session.idle" || !sessionID) return + if (busy.has(sessionID)) return + + const cmd = await readVerify() + if (!cmd) return // Kein VERIFY im Projekt -> Pruef-Tor bewusst aus. + + busy.add(sessionID) + try { + const { ok, out } = await runVerify(cmd) + const round = rounds.get(sessionID) ?? 0 + + if (ok) { + rounds.delete(sessionID) + await announce("[Pruefung]", "Etappe fertig und die Tests sind gruen.", "normal") + return + } + + if (!AUTOFIX || round >= MAX_ROUNDS) { + rounds.delete(sessionID) + await announce( + "[Pruefung]", + `Die Tests sind rot und ich habe ${round} Reparaturversuche verbraucht. ` + + `Hier komme ich allein nicht weiter, Commander.`, + "normal", + ) + return + } + + rounds.set(sessionID, round + 1) + await announce( + "[Pruefung]", + `Tests rot — ich repariere selbst weiter, Runde ${round + 1} von ${MAX_ROUNDS}.`, + "silent", + ) + await sendPrompt( + sessionID, + `[MC2-PRUEFTOR] Deine Etappe gilt noch NICHT als fertig: der Verify-Befehl des ` + + `Projekts ist fehlgeschlagen.\n\n` + + `Befehl: ${cmd}\n\n` + + `Ausgabe (Ende):\n\`\`\`\n${out}\n\`\`\`\n\n` + + `Behebe die Ursache — nicht das Symptom, und schalte keinen Test ab. ` + + `Wenn du fertig bist, melde dich normal; ich pruefe dann erneut. ` + + `(Reparaturrunde ${round + 1} von ${MAX_ROUNDS}.)`, + ) + } finally { + busy.delete(sessionID) + } + }, + } +} + +export default MC2Governor