// explain-permissions.js — opencode V2-Plugin (Plugin-API v2, Core 2.0.x) // // Kanonische Quelle: git.az-gruppe.com/AZ-Intec-GmbH/opencode-plugins // (Tag explain-permissions/v2.0.0). Fleet-Bezug: az-fleet vendort diese // Datei nach config/managed/plugins/ (Pin mit sha256, ADR-0010-Logik — // Byte-Gleichheit ist Vertragsgrundlage). // // V2-only-Cut (v2.0.0, Entscheidung 25.09.): KEIN V1-/Dual-Support-Export // mehr. Die V1-Fleet (1.18.x, gepinnt) lädt bewusst weiter explain-permissions // v1.3.0; diese Datei wird erst mit dem atomaren V2-Rollout gepinnt und // referenziert (ADR-0014: V2 lädt den Managed-Layer bis zur Behebung des // Upstream-Bugs nicht — kein V2-Rollout vorher). // // Zweck (unverändert): Jeden berechtigungspflichtigen Tool-Call laienverständlich // erklären — WAS passiert, WELCHE FOLGEN, WIE RISKANT (Zielgruppe: // nicht-technische Nutzer). // // Was es tut (V2-Mechanik, Migrationsmappe az-fleet-7x8): // 1. Config-Lese-Ersatz für den entfallenen config-Hook: Die Config-Dateien // (Managed-Verzeichnis, Global, Projekt, OPENCODE_CONFIG) werden direkt // per fs gelesen — BEIDE Regel-Formate: V1 permission-Map (String- und // Objekt-Form) und V2 permissions-Array {action, resource, effect}. Die // konkreten ask-/allow-/deny-Muster wandern verbindlich in den // System-Prompt → das Modell weiß, welche Calls eine Freigabe brauchen, // statt zu raten. (Agent-Level-Overrides in agents..permissions sieht // dieser Summary nicht — im Zweifel erklärt das Modell dadurch eher zu // viel als zu wenig.) // 2. ctx.permission.hook("evaluate"): feuert für allow UND ask nach der // Regel-Evaluation, VOR Dialog/Veröffentlichung/Ausführung (explizites // deny ruft den Hook nicht — V2-Semantik). Bei effect==="ask": // JSONL-Audit-Eintrag "ask" + Desktop-Notification mit der DEUTSCHEN // ERKLÄRUNG des Modells (aus der Message vor dem Call extrahiert), // Fallback: Roh-Call. // 3. Event-Stream (ctx.event.subscribe): permission.v2.asked als redundante // Quelle (dieselbe Anfrage wie der Hook — idempotent pro Call dedupliziert), // permission.v2.replied loggt die Entscheidung (once/always/reject). // 4. ctx.session.hook("context"): injiziert die Regel "Erst laienverständlich // erklären (Was/Folgen/Risiko), dann der Call" in den System-Prompt — // läuft für den Agent-Loop inklusive Tool-Continuations (= V1-Verhalten // von experimental.chat.system.transform). Format seit v1.3.0: Zitat-Block // (>) mit fetten Labels, jeder Punkt auf eigener Zeile — TUI und Desktop // rendern das als klar abgesetzten Kasten mit farbigem Balken. // 5. Optional (env OPENCODE_EXPLAIN_ENFORCE=1): ctx.tool.hook("execute.before") // wirft einen Fehler, wenn das Modell einen freigabepflichtigen Call OHNE // vorherige Text-Erklärung im selben Message-Turn absetzt. Default: AUS // (Dialog-Schleifen). Wie in V1 greift ENFORCE nur auf Calls, die durch // einen Freigabe-Dialog gegangen sind (ask-Pfad). // // Notifications je Plattform (unverändert seit v1.2.0): // Linux notify-send (libnotify) // macOS osascript "display notification" (Boardmittel) // Windows PowerShell-Toast via WinRT; AppUserModelID = PowerShell-AUMID // (keine App-Registrierung nötig); Titel/Text über env-Variablen des // Child-Prozesses (injektionssicher), XML-Escape in PowerShell // Alle Zweige fail-soft: fehlendes Binary, headless Session oder Spawn-Fehler // sind stille No-Ops (Node und Bun behandeln ENOENT verschieden — try/catch // plus error-Listener decken beide Semantiken ab). // // V2-Neustart-Semantik: Änderungen an BEOBACHTETEN Config-Verzeichnissen // laden Plugins automatisch neu; die Plugin-Datei selbst (ProgramData/verwaltete // Ablage) sowie Config-Wirksamkeit generell erfordern `opencode service // restart` (der Background-Service besitzt und cachet die Config — ADR-0014). // Der Audit-Log-Eintrag "plugin-loaded" (mit Version) zeigt an, welche Version // wann geladen wurde. // // Konfiguration via Environment-Variablen (unverändert): // OPENCODE_EXPLAIN_NOTIFY=0 Notifications abschalten (Default: an) // OPENCODE_EXPLAIN_LOG=0 Audit-Log abschalten (Default: an) // OPENCODE_EXPLAIN_INJECT=0 System-Prompt-Regel abschalten (Default: an) // OPENCODE_EXPLAIN_ENFORCE=1 Hartes Erzwingen aktivieren (Default: aus) // OPENCODE_EXPLAIN_DEBUG=1 Permission-Events ins Log (Default: aus) // OPENCODE_EXPLAIN_LOG_PATH=… Anderer Log-Pfad // (Default Windows: %LOCALAPPDATA%\opencode\explain-permissions.jsonl — // idiomatisch, roamt nicht; Fallback ~\AppData\Local, wenn LOCALAPPDATA // nicht gesetzt. Entscheidung dokumentiert im README.) // (Default sonst: ~/.local/state/opencode/explain-permissions.jsonl) // // Log ansehen: // tail -f ~/.local/state/opencode/explain-permissions.jsonl | jq . import fs from "node:fs"; import path from "node:path"; import os from "node:os"; import { spawn } from "node:child_process"; const ENV = process.env; const PLUGIN_VERSION = "2.0.0"; const PLUGIN_ID = "az.explain-permissions"; const NOTIFY = ENV.OPENCODE_EXPLAIN_NOTIFY !== "0"; const LOG = ENV.OPENCODE_EXPLAIN_LOG !== "0"; const INJECT = ENV.OPENCODE_EXPLAIN_INJECT !== "0"; const ENFORCE = ENV.OPENCODE_EXPLAIN_ENFORCE === "1"; const DEBUG = ENV.OPENCODE_EXPLAIN_DEBUG === "1"; // Windows: %LOCALAPPDATA% (idiomatisch, roamt nicht); sonst XDG-State im Profil. function defaultLogPath() { if (process.platform === "win32") { const base = ENV.LOCALAPPDATA || (os.homedir() + "\\AppData\\Local"); return base + "\\opencode\\explain-permissions.jsonl"; } return path.join(os.homedir(), ".local", "state", "opencode", "explain-permissions.jsonl"); } const LOG_PATH = ENV.OPENCODE_EXPLAIN_LOG_PATH || defaultLogPath(); const SYSTEM_PROMPT_RULE = `## Tool-Call-Erklärungen (verpflichtend, laienverständlich) Vor JEDEM Tool-Call, der eine Freigabe erfordert, schreibe unmittelbar davor eine Erklärung auf Deutsch, die ein Mensch ohne Computer-Kenntnisse versteht. Format — GENAU SO einhalten (dient der Lesbarkeit und Hebung im Chat): - Die gesamte Erklärung als Zitat-Block: JEDE Zeile beginnt mit „> “ - Jeder der drei Punkte auf EIGENER Zeile — nie alle hintereinander in einer Zeile - Die Labels fettgedruckt > **WAS passiert:** in Alltagssprache — nicht wie der Befehl heißt, sondern was er bewirkt > **WELCHE FOLGEN:** Was ist danach anders? Ist es rückgängig machbar? Sind eigene Dateien gefährdet? > **WIE RISKANT:** harmlos / wiederherstellbar / dauerhaft Beispiel (gut): > **WAS passiert:** Ich lege eine neue, leere Datei namens „Neue Textdatei.txt“ im Ordner „Documents\\Default Project“ an. > **WELCHE FOLGEN:** Es gibt danach eine zusätzliche Datei im Arbeitsordner, die du jederzeit löschen kannst. Nichts Bestehendes wird verändert. > **WIE RISKANT:** harmlos. Beispiel (schlecht, zu technisch): „Ich führe rm -rf /tmp/foo aus.“ Regeln: - Der genaue Befehl darf NACH dem Erklärungs-Block zusätzlich genannt werden, die Erklärung muss aber auch ohne ihn verständlich sein - Kein Fachjargon (rm, rekursiv, force, Pipe, Exit-Code) ohne Umschreibung - Erst die Erklärung, dann der Call — niemals umgekehrt - Rein lesende, automatisch erlaubte Standard-Calls brauchen keine Extra-Erklärung - Im Zweifel, ob ein Call eine Freigabe braucht: immer erklären`; const EXPLAIN_FIRST_ERROR = `EXPLAIN_FIRST: Du hast den Tool-Call ohne vorherige Erklärung abgesetzt. Schreibe zuerst eine Erklärung auf Deutsch, laienverständlich (kein Fachjargon) — als Zitat-Block („> “), jeder Punkt auf eigener Zeile: **WAS passiert:** / **WELCHE FOLGEN:** / **WIE RISKANT:**. Führe den Call danach erneut aus.`; // V2-Action-Namen der Built-in-Tools (V2-Permissions-Doku): bash→shell, // task→subagent; write/patch laufen unter edit. lsp/doom_loop sind KEINE V2- // Core-Actions mehr und werden beim Parsen verworfen. MCP-Tools tragen // _ als Action — unbekannte Namen lassen wir durch (die // Summary listet nur, was konkret konfiguriert ist). const V1_ACTION_ALIASES = { bash: "shell", task: "subagent", write: "edit", patch: "edit" }; const DEAD_V1_ACTIONS = ["lsp", "doom_loop"]; let permissionSummary; // --------------------------------------------------------------------------- // Interner Zustand // --------------------------------------------------------------------------- // Dedup-Schlüssel: bevorzugt die Tool-Call-ID (der evaluate-Hook läuft VOR der // Dialog-Veröffentlichung und kennt die Request-ID noch nicht; das // permission.v2.asked-Event liefert beide). Fallback: Request-ID, sonst eine // strukturelle Beschreibung der Anfrage. const recordedAskKeys = new Set(); const askedCallIDs = new Map(); // callID → requestID (für ENFORCE) function capSet(set, max = 1000) { if (set.size > max) { for (const v of set) { set.delete(v); if (set.size <= max / 2) break; } } } // --------------------------------------------------------------------------- // Audit-Log + Notification // --------------------------------------------------------------------------- function appendLog(entry) { if (!LOG) return; try { fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true }); fs.appendFileSync(LOG_PATH, JSON.stringify(entry) + "\n"); } catch { // Log-Fehler dürfen den Agent nie blockieren } } // Detached Spawn, fail-soft in Node UND Bun: ENOENT wirft/emittiert in den // beiden Runtimes unterschiedlich (Node: asynchrones 'error'-Event, Bun kann // synchron werfen) — try/catch plus error-Listener decken beide Semantiken ab. // stdio 'ignore' + unref: der Agent-Prozess wartet nie auf das Notification- // Binary; fehlendes Binary/headless = stiller No-Op. function runDetached(cmd, args, env) { try { const child = spawn(cmd, args, { detached: true, stdio: "ignore", env: { ...ENV, ...env } }); child.on("error", () => {}); child.unref(); } catch { // fehlendes Binary oder Spawn-Verweigerung → stiller No-Op } } // PowerShell-AppUserModelID (Shell-Folder-GUID von Windows PowerShell): Toasts // erscheinen unter dem Namen "Windows PowerShell", ohne dass eine eigene App // registriert werden muss (AUMID-Standardtrick, kein Shortcut, keine Registry). const TOAST_AUMID = "{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe"; // Titel/Text erreichen PowerShell über env-Variablen des Child-Prozesses — // kein String-Interpolieren in den Skript-Body (injektionssicher); XML-Escape // macht [SecurityElement]::Escape auf der Windows-Seite. const TOAST_PS = [ "$ErrorActionPreference = 'SilentlyContinue'", "[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null", "$xml = New-Object Windows.Data.Xml.Dom.XmlDocument", "$t = [System.Security.SecurityElement]::Escape($env:OC_TOAST_TITLE)", "$b = [System.Security.SecurityElement]::Escape($env:OC_TOAST_BODY)", "$xml.LoadXml('' + $t + '' + $b + '')", "$toast = New-Object Windows.UI.Notifications.ToastNotification $xml", "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('" + TOAST_AUMID + "').Show($toast)", ].join("\n"); function windowsToast(title, body) { const encoded = Buffer.from(TOAST_PS, "utf16le").toString("base64"); runDetached( "powershell.exe", ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-EncodedCommand", encoded], { OC_TOAST_TITLE: title, OC_TOAST_BODY: body }, ); } function macosNotify(title, body) { const esc = (s) => s.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); runDetached("osascript", ["-e", `display notification "${esc(body)}" with title "${esc(title)}"`]); } function notify(title, body) { if (!NOTIFY) return; const b = body.slice(0, 400); if (process.platform === "win32") windowsToast(title, b); else if (process.platform === "darwin") macosNotify(title, b); else runDetached("notify-send", ["-a", "opencode", "-i", "utilities-terminal", "-u", "normal", title, b]); } // Desktop-Notifications render kein Markdown: Zitat-Marker (>) und Fett- // Marker (**) aus der Erklärung entfernen, bevor sie in den Toast geht. function plainText(s) { return s.replace(/^[ \t]*>[ \t]?/gm, "").replace(/\*\*/g, ""); } function debugLog(source, payload) { if (!DEBUG) return; let serialized; try { serialized = JSON.parse(JSON.stringify(payload ?? null)); } catch { serialized = String(payload); } appendLog({ ts: new Date().toISOString(), event: "debug", source, payload: serialized }); } // --------------------------------------------------------------------------- // Config-Lesung (Ersatz für den entfallenen V1-config-Hook) // --------------------------------------------------------------------------- // JSONC-tolerantes Strippen: Zeilen-/Blockkommentare und abschließende Kommas // NUR außerhalb von String-Literalen entfernen (Zustandsautomat über das // Zeichenfenster — kein Regex, der Strings zerstören könnte). Best effort: // Parse-Fehler fallen im Caller still durch (fail-soft, Summary bleibt leer). function stripJsonc(text) { let out = ""; let i = 0; let inString = false; while (i < text.length) { const c = text[i]; const next = text[i + 1]; if (inString) { out += c; if (c === "\\") { if (i + 1 < text.length) out += next; i += 2; continue; } if (c === '"') inString = false; i += 1; continue; } if (c === '"') { inString = true; out += c; i += 1; continue; } if (c === "/" && next === "/") { while (i < text.length && text[i] !== "\n") i += 1; continue; } if (c === "/" && next === "*") { i += 2; while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i += 1; i += 2; continue; } out += c; i += 1; } // Abschließende Kommas vor } oder ] entfernen (außerhalb von Strings — nach // dem Kommentar-Strip sicher, weil nur noch ",}" / ",]"-Muster mit optionalen // Whitespaces übriggs bleiben können). return out.replace(/,(\s*[}\]])/g, "$1"); } function parseConfigFile(file) { try { return JSON.parse(stripJsonc(fs.readFileSync(file, "utf8"))); } catch { return undefined; } } // Systemweites Managed-Verzeichnis (V1-Fleet-Anker; V2 lädt es Stand 2.0.16 // nicht — siehe ADR-0014 —, aber lesen schadet nicht und greift, sobald der // Upstream-Bug behoben ist): Windows %ProgramData%\opencode, sonst die // plattformüblichen Pendants. function managedConfigDir() { if (process.platform === "win32") { return path.join(ENV.ProgramData || "C:\\ProgramData", "opencode"); } if (process.platform === "darwin") { return "/Library/Application Support/opencode"; } return "/etc/opencode"; } // Kandidaten in Ladereihenfolge niedrig→hoch; innerhalb eines Verzeichnisses // gilt jsonc-vor-json (json lädt später und gewinnt). Spätere Dateien hängen // ihre Regeln an → approximiert „letzte passende Regel gewinnt“ über Layer. function configCandidates(projectDir) { const files = []; const pushDir = (dir) => { if (!dir) return; files.push(path.join(dir, "opencode.jsonc"), path.join(dir, "opencode.json")); }; pushDir(managedConfigDir()); pushDir(path.join(os.homedir(), ".config", "opencode")); if (projectDir) { pushDir(projectDir); pushDir(path.join(projectDir, ".opencode")); } if (ENV.OPENCODE_CONFIG) files.push(ENV.OPENCODE_CONFIG); return files; } // V1-Action-Key normalisieren; null = verwerfen (in V2 tote Actions). function normalizeAction(key) { if (typeof key !== "string" || !key) return null; const action = V1_ACTION_ALIASES[key] ?? key; return DEAD_V1_ACTIONS.includes(key) ? null : action; } function addRule(summary, action, pattern, effect) { if (!action || typeof pattern !== "string" || !pattern) return; if (effect !== "ask" && effect !== "allow" && effect !== "deny") return; const entry = (summary[action] ??= { asks: [], allows: [], denys: [] }); const list = effect === "ask" ? entry.asks : effect === "allow" ? entry.allows : entry.denys; // Layer-Merge kann dasselbe Muster mehrfach liefern (Global + Projekt) — // Reihenfolge erhalten, Dublette überspringen. if (!list.includes(pattern)) list.push(pattern); } // V1-Form: permission: { : "ask"|"allow"|"deny" | { : } } function extractV1Permission(config, summary) { const permission = config?.permission; if (!permission || typeof permission !== "object" || Array.isArray(permission)) return; for (const [key, rule] of Object.entries(permission)) { const action = normalizeAction(key); if (!action) continue; if (typeof rule === "string") { addRule(summary, action, "*", rule); } else if (rule && typeof rule === "object") { for (const [pattern, effect] of Object.entries(rule)) { if (typeof effect === "string") addRule(summary, action, pattern, effect); } } } } // V2-Form: permissions: [ { action, resource, effect } ] (geordnetes Array) function extractV2Permissions(config, summary) { const permissions = config?.permissions; if (!Array.isArray(permissions)) return; for (const rule of permissions) { if (!rule || typeof rule !== "object") continue; const action = normalizeAction(rule.action); if (action) addRule(summary, action, rule.resource ?? "*", rule.effect); } } function readPermissionSummary(projectDir) { const summary = {}; for (const file of configCandidates(projectDir)) { const config = parseConfigFile(file); if (!config) continue; if (DEBUG) debugLog(`config:${file}`, { permission: config.permission ?? null, permissions: config.permissions ?? null }); extractV1Permission(config, summary); extractV2Permissions(config, summary); } return Object.keys(summary).length ? summary : undefined; } function buildSystemRule() { if (!permissionSummary) return SYSTEM_PROMPT_RULE; const lines = []; for (const [action, { asks, allows, denys }] of Object.entries(permissionSummary)) { let line = `- ${action}: `; if (asks.length) line += `Freigabe nötig → erklären! Muster: ${asks.join(", ")}`; if (allows.length) { line += (asks.length ? " | automatisch erlaubt: " : "automatisch erlaubt: "); line += allows.join(", "); } if (denys.length) { line += (asks.length || allows.length ? " | blockiert: " : "blockiert: "); line += denys.join(", "); } lines.push(line); } return `${SYSTEM_PROMPT_RULE} Diese Muster lösen laut opencode-Konfiguration eine Freigabe aus. Die Liste ist verbindlich: Erkläre JEDEN Call, der darauf passt — auch wenn er dir harmlos erscheint oder der User ihn explizit angefordert hat: ${lines.join("\n")}`; } // --------------------------------------------------------------------------- // Ask-/Reply-Protokollierung // --------------------------------------------------------------------------- // Vereinheitlichte Anfrage aus den beiden Quellen (evaluate-Hook, // permission.v2.asked-Event). Felder sind optional; callID ist die Tool-Call-ID // aus source (V2: source.id), requestID die Permission-Request-ID. function permissionCall(p) { return ( p.metadata?.command ?? (Array.isArray(p.resources) ? p.resources.join(" | ") : undefined) ?? p.title ?? "unbekannter Call" ); } function askKey(p) { return ( p.callID ?? p.requestID ?? (p.sessionID ? `${p.sessionID}:${p.action}:${Array.isArray(p.resources) ? p.resources.join("|") : ""}` : undefined) ); } /** * Freigabe-Anfrage protokollieren. Idempotent pro Tool-Call (evaluate-Hook und * permission.v2.asked liefern dieselbe Anfrage). Liefert true nur bei der * ersten Aufzeichnung dieses Calls. */ function recordAsk(p) { if (!p || typeof p !== "object") return false; const key = askKey(p); if (!key) return false; if (recordedAskKeys.has(key)) return false; recordedAskKeys.add(key); capSet(recordedAskKeys); if (p.callID) askedCallIDs.set(p.callID, p.requestID); appendLog({ ts: new Date().toISOString(), event: "ask", permissionID: p.requestID, sessionID: p.sessionID, messageID: p.messageID, callID: p.callID, action: p.action, call: permissionCall(p), resources: Array.isArray(p.resources) ? p.resources : undefined, alwaysSuggestions: Array.isArray(p.save) ? p.save : undefined, }); return true; } function recordReplied(props) { appendLog({ ts: new Date().toISOString(), event: "replied", permissionID: props?.requestID ?? props?.permissionID, sessionID: props?.sessionID, reply: props?.reply ?? props?.response, }); } // --------------------------------------------------------------------------- // Message-Kontext: Text-Parts VOR dem Tool-Part derselben Message // --------------------------------------------------------------------------- /** * Den Message-Turn untersuchen, der den Tool-Call enthält. * V2: ctx.session.context({sessionID}) liefert die Messages; die Message-Teile * liegen im Feld `content` (verifiziert vm-test 2.0.16, az-fleet-7x8 — ältere * SDK-Typen nennen es `parts`, beides wird gelesen) mit {type:"text", text} * und {type:"tool", id, …}. Die Tool-Call-ID des Permission-Source (source.id) * matcht die Tool-Part-ID (gleiches tooluse_-Format); trägt der Tool-Part * keine ID, fällt die Suche auf den ersten Tool-Part der Message zurück. * Liefert { found: true, explanation } mit den Text-Parts vor dem Tool-Part, * { found: true } ohne Erklärung, oder { found: false }, wenn Call oder API * nicht verfügbar sind — Caller behandeln found:false als „niemals blocken, * nie anreichern" (fail-open). */ async function inspectCallContext(ctx, sessionID, callID, messageID) { try { const messages = await ctx.session.context({ sessionID }); if (!Array.isArray(messages)) { if (DEBUG) debugLog("inspect", { callID, result: "kein Nachrichten-Array" }); return { found: false }; } const dbg = { callID, nMsgs: messages.length }; const isToolMatch = (part) => part?.type === "tool" && (part.id === callID || part.callID === callID); const scan = (parts, allowFallback) => { let toolIndex = parts.findIndex(isToolMatch); // Fallback (nur im messageID-Zweig, wo die Message feststeht): trägt der // Tool-Part keine ID, den ersten Tool-Part derselben Message nehmen. if (toolIndex === -1 && allowFallback && parts.some((p) => p?.type === "tool")) { toolIndex = parts.findIndex((p) => p?.type === "tool"); } if (toolIndex === -1) return undefined; const texts = []; for (let i = 0; i < toolIndex; i++) { const part = parts[i]; if (part?.type === "text" && typeof part.text === "string" && part.text.trim()) { texts.push(part.text.trim()); } } return { found: true, explanation: texts.length ? texts.join("\n") : undefined }; }; const messageParts = (msg) => { if (Array.isArray(msg?.parts)) return msg.parts; if (Array.isArray(msg?.content)) return msg.content; return null; }; // Bevorzugt die Message aus dem Permission-Source (messageID), Fallback: // alle Messages scannen (dort ohne Approximation — nur exakte Treffer). if (messageID) { const msg = messages.find((m) => m?.id === messageID); const parts = msg ? messageParts(msg) : null; dbg.msgFound = !!msg; dbg.partTypes = parts ? parts.map((p) => p.type).join(",") : null; if (parts) { const hit = scan(parts, true); if (hit) { if (DEBUG) debugLog("inspect", { ...dbg, via: "messageID+scan", explained: !!hit.explanation }); return hit; } // Persistenz-Lag (vm-test 2.0.16): Zur Evaluations-Zeit ist der // Tool-Part im Message-Kontext ggf. noch nicht sichtbar. Die Message // gehört aber genau zu diesem Call — alle ihre Text-Parts liegen per // Definition VOR dem Call (der Call löst die Evaluation aus). const texts = parts .filter((p) => p?.type === "text" && typeof p.text === "string" && p.text.trim()) .map((p) => p.text.trim()); if (DEBUG) debugLog("inspect", { ...dbg, via: "messageID+lag", explained: texts.length > 0 }); return { found: true, explanation: texts.length ? texts.join("\n") : undefined }; } } for (const msg of messages) { const parts = messageParts(msg); if (!parts) continue; const hit = scan(parts, false); if (hit) { if (DEBUG) debugLog("inspect", { ...dbg, via: "global-scan", explained: !!hit.explanation }); return hit; } } if (DEBUG) debugLog("inspect", { ...dbg, via: "not-found" }); return { found: false }; } catch (err) { if (DEBUG) debugLog("inspect", { callID, error: String(err) }); return { found: false }; } } async function explainAndNotify(ctx, p) { const rawCall = permissionCall(p); const type = p.action ?? "Tool"; let explanation; if (p.callID) { const result = await inspectCallContext(ctx, p.sessionID, p.callID, p.messageID); explanation = result.explanation; } if (explanation) { appendLog({ ts: new Date().toISOString(), event: "ask.explained", permissionID: p.requestID, callID: p.callID, explanation: explanation.slice(0, 2000), }); } const body = explanation ? `${plainText(explanation)}\n—\n${type}: ${rawCall}\n→ im opencode-UI antworten` : `${rawCall}\n→ im opencode-UI antworten`; notify(`opencode · Freigabe nötig (${type})`, body); } // --------------------------------------------------------------------------- // Plugin (V2: Plugin.define + default-Export, Hooks via setup registriert) // --------------------------------------------------------------------------- async function setup(ctx) { appendLog({ ts: new Date().toISOString(), event: "plugin-loaded", version: PLUGIN_VERSION, id: PLUGIN_ID, opencode: ctx?.app?.version, directory: ctx?.location?.directory, }); permissionSummary = readPermissionSummary(ctx?.location?.directory); if (DEBUG) debugLog("permission-summary", permissionSummary ?? "keine"); if (INJECT) { // Agent-Loop inkl. Tool-Continuations (= V1 experimental.chat.system.transform) await ctx.session.hook("context", (event) => { const rule = buildSystemRule(); if (DEBUG) debugLog("system-rule", rule); event.system.push({ type: "text", text: rule }); }); } // Feuert für allow UND ask nach Regel-Evaluation, vor Dialog/Ausführung; // explizites deny ruft den Hook nicht. Beobachter-Status: ändert effect nie. await ctx.permission.hook("evaluate", (event) => { debugLog("permission.evaluate", event); if (event?.effect !== "ask") return; const rec = { sessionID: event.sessionID, action: event.action, resources: event.resources, metadata: event.metadata, messageID: event.source?.messageID, callID: event.source?.id ?? event.source?.callID, }; if (recordAsk(rec)) void explainAndNotify(ctx, rec); }); // Event-Stream: asked als redundante Quelle (Dedup über die Call-ID), // replied für das Audit der Entscheidung. Envelope tolerant lesen // (properties || data || flach), Abruch über den Cleanup-Return. const controller = new AbortController(); void (async () => { try { for await (const ev of ctx.event.subscribe({ signal: controller.signal })) { const type = ev?.type; const props = ev?.properties ?? ev?.data ?? ev; if (DEBUG && typeof type === "string" && type.startsWith("permission")) { debugLog(`event:${type}`, props); } if (type === "permission.v2.asked") { const rec = { requestID: props?.id, sessionID: props?.sessionID, action: props?.action, resources: props?.resources, metadata: props?.metadata, save: props?.save, messageID: props?.source?.messageID, callID: props?.source?.id ?? props?.source?.callID, }; if (recordAsk(rec)) void explainAndNotify(ctx, rec); } else if (type === "permission.v2.replied") { recordReplied(props); } } } catch { // Stream-Abbruch (Cleanup) oder Transportfehler → stiller No-Op } })(); if (ENFORCE) { await ctx.tool.hook("execute.before", async (event) => { const callID = event?.callID ?? event?.id; if (!callID || !askedCallIDs.has(callID)) return; const result = await inspectCallContext(ctx, event.sessionID, callID, event.messageID); if (!result.found || result.explanation) return; askedCallIDs.delete(callID); throw new Error(EXPLAIN_FIRST_ERROR); }); } return () => controller.abort(); } // Plugin.define ist die getypte Identitätsfunktion der V2-API. Der Import wird // bewusst DYNAMISCH gehalten mit Fallback auf die Rohestform {id, setup}: // Schlägt die Paketauflösung fehl (z. B. Einzel-Datei-Package außerhalb eines // npm-Kontexts), wirf der statische Import das Laden der GESAMTEN Datei — // dynamisch bleibt der Fleet-Load fail-soft und das Plugin trotzdem aktiv. const definition = { id: PLUGIN_ID, setup }; export default await (async () => { try { const mod = await import("@opencode/plugin"); const define = mod?.Plugin?.define; if (typeof define === "function") return define(definition); } catch { // Fallback unten } return definition; })();