/** * Arbeitsweise-Guard Extension * * Aktiv NUR wenn PI_ORCHESTRATOR=1 (d.h. gestartet über OrchestratorPi). * * Blockiert Tool-Aufrufe, die typische Subagenten-Arbeit sind, und zeigt * eine lokale Benachrichtigung in der Orchestrator-Session. Kein Intercom, * keine externe Verbindung nötig. * * Erlaubt im Orchestrator: * - Alle lesenden Operationen (read, grep, find, ls, ...) * - Schreiben in ~/.pi/agent/memory/ * - Schreiben in /tmp/subagent-* (Übergabe-Dateien für Subagenten) * - SESSION_HANDOVER.md und AGENTS.md * - Die eigene Guard-Datei * - SubAgenten starten (bash: SubAgenten-Befehl) * - Intercom-Kommandos * * Blockiert im Orchestrator: * - write/edit auf Projektpfade ausserhalb der erlaubten Pfade * - bash: curl, wget, ssh, scp, rsync, apt/pip/npm install, * git clone/push/commit (ausser in /tmp/subagent-*), * firecrawl, tavily, jina, playwright, pkexec */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { isToolCallEventType } from "@earendil-works/pi-coding-agent"; import { execSync } from "node:child_process"; import { readFileSync, writeFileSync, existsSync } from "node:fs"; // Nur im Orchestrator aktiv — in Subagenten-Sessions keine Wirkung. const ORCHESTRATOR_AKTIV = !!process.env.PI_ORCHESTRATOR; // --------------------------------------------------------------------------- // Konfiguration // --------------------------------------------------------------------------- const ERLAUBTE_SCHREIB_PRAEFIXE = [ "/home/xray/.pi/agent/memory/", "/home/xray/.pi/subagents/", "/tmp/subagent-", // Fallback für bestehende Sessions ]; const ERLAUBTE_SCHREIB_DATEIEN = [ "AGENTS.md", "SESSION_HANDOVER.md", "arbeitsweise-guard.ts", ]; const VERDAECHTIGE_BASH: Array<{ muster: string; label: string; }> = [ { muster: "curl ", label: "HTTP-Request (curl)" }, { muster: "curl\t", label: "HTTP-Request (curl)" }, { muster: "wget ", label: "HTTP-Download (wget)" }, { muster: "ssh ", label: "SSH-Verbindung" }, { muster: "scp ", label: "Datei-Transfer (scp)" }, { muster: "rsync ", label: "Datei-Transfer (rsync)" }, { muster: "apt install", label: "Paketinstallation (apt)" }, { muster: "apt-get install", label: "Paketinstallation (apt-get)" }, { muster: "pip install", label: "Paketinstallation (pip)" }, { muster: "pip3 install", label: "Paketinstallation (pip3)" }, { muster: "npm install", label: "Paketinstallation (npm)" }, { muster: "git clone", label: "Repository klonen (git clone)" }, { muster: "git push", label: "Git push" }, { muster: "git commit", label: "Git commit" }, { muster: "firecrawl", label: "Web-Crawling (firecrawl)" }, { muster: "tavily", label: "Web-Suche (tavily)" }, { muster: "jina", label: "Web-Recherche (jina)" }, { muster: "playwright", label: "Browser-Automation (playwright)" }, { muster: "pkexec", label: "Privilegierte Ausfuehrung (pkexec)" }, { muster: "sleep ", label: "Kuenstliches Warten (sleep)" }, ]; // Bash-Befehle die der Orchestrator selbst ausführen darf. const ERLAUBTE_BASH_PRAEFIXE = [ "ls", "cat ", "cat\t", "grep ", "grep\t", "find ", "find\t", "rg ", "rg\t", "ps", "stat ", "file ", "head ", "tail ", "wc", "date", "echo ", "echo\t", "pwd", "test ", "mkdir -p /tmp/subagent-", "mkdir -p ~/.pi/agent/", "SubAgenten ", "subagenten ", "SubStatus", "substatus", "intercom ", "intercom\t", "which ", "type ", "command -v ", "env ", "printenv ", "tmux ", "tmux\t", ]; // --------------------------------------------------------------------------- // Hilfsfunktionen // --------------------------------------------------------------------------- function istErlaubterSchreibPfad(pfad: string): boolean { const p = pfad.startsWith("~/") ? process.env.HOME + pfad.slice(1) : pfad; if (ERLAUBTE_SCHREIB_PRAEFIXE.some((pref) => p.startsWith(pref))) return true; const basename = p.split("/").pop() ?? ""; if (ERLAUBTE_SCHREIB_DATEIEN.includes(basename)) return true; // .pi/agent/memory via Regex if (/\/\.pi\/agent\/memory\//.test(p)) return true; return false; } function pruefeWriteEdit( toolName: string, input: { path?: string; file_path?: string }, ): string | null { const pfad = input.path ?? input.file_path ?? ""; if (!pfad) return null; if (istErlaubterSchreibPfad(pfad)) return null; return `${toolName} auf "${pfad}" → ausserhalb Orchestrator-Pfade. Bitte an Subagent delegieren.`; } function pruefeBasch(input: { command?: string }): string | null { const cmd = input.command ?? ""; if (!cmd) return null; const trimmed = cmd.trim(); // Erlaubte Praefixe freigeben. for (const pref of ERLAUBTE_BASH_PRAEFIXE) { if (trimmed === pref.trimEnd() || trimmed.startsWith(pref)) return null; } // Verdaechtige Muster pruefen. for (const { muster, label } of VERDAECHTIGE_BASH) { if (!cmd.includes(muster)) continue; // git commit/push in Subagent-Verzeichnissen ist erlaubt. if ( (muster === "git commit" || muster === "git push") && (cmd.includes("/tmp/subagent-") || cmd.includes("/.pi/subagents/")) ) { return null; } return `bash "${label}" → Subagenten-Arbeit. Bitte an Subagent delegieren.`; } return null; } // --------------------------------------------------------------------------- // tmux-Status-Abfrage (fuer tool_result-Hook) // --------------------------------------------------------------------------- function tmuxSubagentenStatus(): string | null { try { const raw = execSync("tmux ls -F '#{session_name}' 2>/dev/null || true", { encoding: "utf-8", timeout: 2000, }).trim(); if (!raw) return null; const sessions = raw.split("\n").map((s) => s.trim()).filter(Boolean); if (sessions.length === 0) return null; const fertigSessions: string[] = []; const lines = sessions.map((name) => { try { const lastLine = execSync( `tmux capture-pane -t '${name}' -p 2>/dev/null | grep -v '^[[:space:]]*$' | tail -1`, { encoding: "utf-8", timeout: 1000 }, ).trim().slice(0, 100); // W03: Beendete Sessions erkennen und explizit markieren. if (lastLine.includes("Pi beendet") || lastLine.includes("=== Pi beendet ===") || lastLine.includes("Fenster kann geschlossen werden")) { fertigSessions.push(name); return ` • ${name}: ✅ FERTIG → tmux kill-session -t ${name}`; } return ` • ${name}: ${lastLine || "(leer)"}`; } catch { return ` • ${name}`; } }); let result = `[Auto-Check tmux] ${sessions.length} Session(s) aktiv:\n${lines.join("\n")}`; // W03: Wenn fertige Sessions erkannt, expliziten Hinweis hinzufügen. if (fertigSessions.length > 0) { result += `\n\n⚠️ [W03] ${fertigSessions.length} fertige Session(s) — bitte schliessen:\n` + fertigSessions.map((n) => ` tmux kill-session -t ${n}`).join("\n"); } return result; } catch { return null; } } // --------------------------------------------------------------------------- // Extension // --------------------------------------------------------------------------- export default function (pi: ExtensionAPI) { if (!ORCHESTRATOR_AKTIV) return; pi.on("tool_call", async (event, ctx) => { let grund: string | null = null; if (isToolCallEventType("write", event)) { grund = pruefeWriteEdit("write", event.input as { path?: string }); } else if (isToolCallEventType("edit", event)) { grund = pruefeWriteEdit("edit", event.input as { file_path?: string }); } else if (isToolCallEventType("bash", event)) { grund = pruefeBasch(event.input as { command?: string }); } if (!grund) return; ctx.ui.notify( `[Orchestrator-Guard] Blockiert: ${grund}`, "warning", ); return { block: true, reason: grund }; }); // Nach jedem Tool-Result: tmux-Status automatisch anhängen. // Verhindert, dass der Orchestrator sleep-Intervalle erfindet um // Subagenten-Status zu prüfen — er sieht den Status direkt im Result. pi.on("tool_result", async (_event) => { const status = tmuxSubagentenStatus(); // W08: Intercom-Hänger prüfen. const extraHinweis = pruefeIntercomHaenger(); // W02: SubWatcher-Alerts aus Alert-Datei lesen und einmalig anzeigen. const subWatcherAlerts = leseSubWatcherAlerts(); const extras = [status, extraHinweis, subWatcherAlerts].filter(Boolean).join("\n\n"); if (!extras) return; return { content: [ ..._event.content, { type: "text" as const, text: `\n\n${extras}` }, ], }; }); } // --------------------------------------------------------------------------- // W08: Intercom-Hänger-Check (nach SubAgent-Starts) // --------------------------------------------------------------------------- function pruefeIntercomHaenger(): string | null { try { // 'intercom' ist kein Shell-Befehl sondern ein Pi-internes Tool — // dieser Check funktioniert nur wenn Pi intercom im PATH bereitstellt. const raw = execSync("intercom pending 2>/dev/null || true", { encoding: "utf-8", timeout: 3000, }).trim(); // Kein Hänger wenn leer oder "No unresolved inbound asks" if (!raw || raw.includes("No unresolved") || raw.includes("no pending")) return null; return `[Auto-Check intercom] Offene Rückfragen:\n${raw.slice(0, 300)}`; } catch { return null; } } // --------------------------------------------------------------------------- // W02: SubWatcher-Alerts — von SubWatcher geschriebene Benachrichtigungen // --------------------------------------------------------------------------- function leseSubWatcherAlerts(): string | null { const alertFile = "/tmp/.pi-subagent-alert"; try { if (!existsSync(alertFile)) return null; const content = readFileSync(alertFile, "utf-8").trim(); if (!content) return null; // Alert-Datei leeren nach dem Lesen (einmalige Anzeige) writeFileSync(alertFile, ""); return `📬 [W02 SubWatcher-Alerts]:\n${content}`; } catch { return null; } }