pi-system/extensions/arbeitsweise-guard.ts

333 lines
12 KiB
TypeScript
Raw Normal View History

/**
* 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 { Type } from "typebox";
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 -5`,
{ encoding: "utf-8", timeout: 1000 },
).trim().slice(0, 500);
// 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}`;
}
// Dialog-Erkennung: Erlauben/Ablehnen-Prompt sichtbar machen.
if (lastLine.includes("Erlauben?") || lastLine.includes("→ Yes") || lastLine.includes("navigate") || lastLine.includes("ACHTUNG:")) {
return `${name} ⚠️ WARTET AUF BESTÄTIGUNG:\n${lastLine.split("\n").map(l => ` ${l}`).join("\n")}`;
}
return `${name}: ${lastLine.split("\n").pop() || "(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;
// -------------------------------------------------------------------------
// watch_subagents — Proaktiver 30s-Watcher
//
// Wartet 30 Sekunden, dann gibt es den aktuellen Status aller tmux-Sessions
// zurück. Der Orchestrator MUSS dieses Tool sofort nach dem Return neu
// aufrufen — so entsteht eine permanente 30s-Polling-Schleife ohne externen
// Prozess. AbortSignal wird respektiert: bei Nutzer-Input endet der Sleep
// sofort und der Status wird sofort zurückgegeben.
// -------------------------------------------------------------------------
pi.registerTool({
name: "watch_subagents",
label: "Subagenten-Watcher (30s)",
description:
"Wartet 30 Sekunden und prüft dann alle Subagenten-Sessions. " +
"PFLICHT: Nach dem Return sofort wieder aufrufen. " +
"Nur im Orchestrator aktiv. Nutzer-Input unterbricht den Sleep sofort.",
parameters: Type.Object({}),
execute: async (_toolCallId, _params, signal, _onUpdate, _ctx) => {
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, 30_000);
signal?.addEventListener("abort", () => {
clearTimeout(timer);
resolve();
});
});
const status = tmuxSubagentenStatus();
const alerts = leseSubWatcherAlerts();
const parts = ["⏱️ [watch_subagents] 30s-Check:"];
if (status) parts.push(status);
if (alerts) parts.push(alerts);
if (!status && !alerts) parts.push("Alle Sessions aktiv, kein Handlungsbedarf.");
return {
content: [{ type: "text" as const, text: parts.join("\n\n") }],
details: {},
};
},
});
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;
}
}