Das Plugin existierte nur im Konfigordner des PCs - ohne Sicherung, ohne Historie. Zaun-Regel 'git push' entfernt (jede Aenderung soll im git landen), dafuer gezielte Sperre fuer --force/--mirror/--delete. Beides gemessen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
256 lines
12 KiB
TypeScript
256 lines
12 KiB
TypeScript
/**
|
|
* 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<string, number>()
|
|
/** Doppel-Feuern verhindern: session.idle kann mehrfach kommen. */
|
|
const busy = new Set<string>()
|
|
|
|
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<string | null> {
|
|
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<boolean> {
|
|
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
|