// explain-permissions.js — opencode plugin // // Kanonische Quelle: git.az-gruppe.com/AZ-Intec-GmbH/opencode-plugins // (Tag explain-permissions/v1.2.0). Fleet-Bezug: az-fleet vendort diese // Datei nach config/managed/plugins/ (Pin mit sha256 dort in // roles/managed_config — Byte-Gleichheit ist Vertragsgrundlage). // // Zweck: Jeden berechtigungspflichtigen Tool-Call laienverständlich erklären — // WAS passiert, WELCHE FOLGEN, WIE RISKANT (Zielgruppe: nicht-technische Nutzer). // // Was es tut: // 1. config-Hook: liest die permission-Sektion der opencode-Config und injiziert // die konkreten ask-/allow-Muster verbindlich in den System-Prompt → das Modell // weiß genau, welche Calls eine Freigabe brauchen, statt zu raten. // (Achtung: Agent-Level-Overrides in agents/*.md sieht dieser Hook nicht — // im Zweifel erklärt das Modell dadurch eher zu viel als zu wenig.) // 2. permission.asked-Event (feuert immer, wenn der Permission-Dialog erscheint): // → Desktop-Notification mit der DEUTSCHEN ERKLÄRUNG des Modells (aus der // Message vor dem Call extrahiert) + Roh-Befehl als Detail; Fallback: Roh-Befehl // → Einträge in JSONL-Audit-Log: "ask" (Roh-Call) + "ask.explained" (Erklärung) // (permission.ask Hook ist registriert, feuert in v1.18.21 aber nicht; // permission.replied loggt die Entscheidung: once/always/reject) // 3. experimental.chat.system.transform: // → injiziert die Regel "Erst laienverständlich erklären (Was/Folgen/Risiko), // dann der Call" in den System-Prompt — in jeder Session, auch in Worktrees // ohne eigenes AGENTS.md // 4. Optional (env OPENCODE_EXPLAIN_ENFORCE=1): tool.execute.before wirft einen // Fehler, wenn das Modell einen berechtigungspflichtigen Call OHNE vorherige // Text-Erklärung im selben Message-Turn absetzt. Default: AUS (Dialog-Schleifen). // // Notifications je Plattform (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). // // Wichtig: Plugin-Änderungen greifen erst nach NEUSTART von opencode — eine neue // Chat-Session im laufenden Prozess reicht nicht. Der Audit-Log-Eintrag // "plugin-loaded" (mit Version) zeigt an, welche Version wann geladen wurde. // // Konfiguration via Environment-Variablen: // 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 = "1.2.0"; 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. Struktur: - 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): „Ich lösche jetzt den Ordner „foo" im temporären Bereich samt allem, was darin liegt. Das lässt sich nicht rückgängig machen — es betrifft aber nur diesen einen Ordner, deine anderen Dateien bleiben unberührt." Beispiel (schlecht, zu technisch): „Ich führe rm -rf /tmp/foo aus." Regeln: - Der genaue Befehl darf NACH der Erklärung 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 1-2 Sätze auf Deutsch, laienverständlich (kein Fachjargon): Was macht der Call, welche Folgen hat er, wie riskant ist er? Führe den Call danach erneut aus.`; const PERMISSION_KEYS = [ "bash", "edit", "webfetch", "external_directory", "read", "glob", "grep", "task", "lsp", "skill", ]; let permissionSummary; // --------------------------------------------------------------------------- // Interner Zustand // --------------------------------------------------------------------------- const recordedPermissionIds = new Set(); const askedCallIDs = new Map(); 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]); } 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 }); } // Runtime-Payload von permission.asked (v1.18.21, per Debug-Lauf verifiziert): // { id, sessionID, permission, patterns[], metadata{command}, always[], tool{messageID, callID} } // permission.replied: { sessionID, requestID, reply } // permission.ask Hook ist registriert (Future-Proofing), feuert in 1.18.21 aber nicht. function permissionType(p) { return p.permission ?? p.type; } function permissionCall(p) { return ( p.metadata?.command ?? (Array.isArray(p.patterns) ? p.patterns.join(" | ") : undefined) ?? p.title ?? "unbekannter Call" ); } // Aus der opencode-Config (config-Hook): pro Permission-Key die "ask"- und // "allow"-Muster. "*" bei ask = jeder Call fragt, allow-Ausnahmen entschärfen. function extractPermissionSummary(config) { const permission = config?.permission; if (!permission || typeof permission !== "object") return undefined; const summary = {}; for (const key of PERMISSION_KEYS) { const rule = permission[key]; if (!rule) continue; let asks = []; let allows = []; if (rule === "ask") { asks = ["**"]; } else if (rule === "allow") { allows = ["**"]; } else if (typeof rule === "object") { for (const [pattern, action] of Object.entries(rule)) { if (action === "ask") asks.push(pattern); else if (action === "allow") allows.push(pattern); } } if (asks.length || allows.length) summary[key] = { asks, allows }; } return Object.keys(summary).length ? summary : undefined; } function buildSystemRule() { if (!permissionSummary) return SYSTEM_PROMPT_RULE; const lines = []; for (const [key, { asks, allows }] of Object.entries(permissionSummary)) { let line = `- ${key}: `; 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(", "); } 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")}`; } /** * Record a permission request. Idempotent per permission id * (permission.ask Hook und permission.asked/updated Events liefern dieselbe Anfrage). * Returns true only for the first recording of this permission id. */ function recordAsk(p) { if (!p || typeof p !== "object" || !p.id) return false; if (recordedPermissionIds.has(p.id)) return false; recordedPermissionIds.add(p.id); capSet(recordedPermissionIds); const callID = p.tool?.callID ?? p.callID; if (callID) askedCallIDs.set(callID, p.id); appendLog({ ts: new Date().toISOString(), event: "ask", permissionID: p.id, sessionID: p.sessionID, messageID: p.tool?.messageID ?? p.messageID, callID, type: permissionType(p), call: permissionCall(p), patterns: Array.isArray(p.patterns) ? p.patterns : undefined, alwaysSuggestions: Array.isArray(p.always) ? p.always : 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 Assistant-Message // --------------------------------------------------------------------------- /** * Inspect the message turn that contains the tool call. * Returns { found: true, explanation } with the joined text parts written * before the tool part, { found: true } without explanation if none exist, * or { found: false } when the call or API is unavailable — callers must * treat found:false as "never block, never enrich" (fail-open). */ async function inspectCallContext(client, sessionID, callID) { try { const result = await client.session.messages({ path: { id: sessionID } }); const messages = result?.data ?? result; if (!Array.isArray(messages)) return { found: false }; for (const msg of messages) { const parts = msg?.parts; if (!Array.isArray(parts)) continue; const toolIndex = parts.findIndex( (part) => part?.type === "tool" && part?.callID === callID, ); if (toolIndex === -1) continue; 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 }; } return { found: false }; } catch { return { found: false }; } } async function explainAndNotify(client, p) { const callID = p.tool?.callID ?? p.callID; const rawCall = permissionCall(p); const type = permissionType(p) ?? "Tool"; let explanation; if (callID) { const ctx = await inspectCallContext(client, p.sessionID, callID); explanation = ctx.explanation; } if (explanation) { appendLog({ ts: new Date().toISOString(), event: "ask.explained", permissionID: p.id, explanation: explanation.slice(0, 2000), }); } const body = explanation ? `${explanation}\n—\n${type}: ${rawCall}\n→ im opencode-UI antworten` : `${rawCall}\n→ im opencode-UI antworten`; notify(`opencode · Freigabe nötig (${type})`, body); } // --------------------------------------------------------------------------- // Plugin // --------------------------------------------------------------------------- export const ExplainPermissionsPlugin = async ({ client }) => { const hooks = {}; appendLog({ ts: new Date().toISOString(), event: "plugin-loaded", version: PLUGIN_VERSION }); hooks.config = async (config) => { permissionSummary = extractPermissionSummary(config); if (DEBUG) debugLog("permission-summary", permissionSummary ?? "keine"); }; // Beobachter-Status: liest input, ändert output.status nie. hooks["permission.ask"] = async (input) => { debugLog("permission.ask-hook", input); if (recordAsk(input)) void explainAndNotify(client, input); }; hooks.event = async ({ event }) => { const type = event?.type; const props = event?.properties ?? {}; if (DEBUG && typeof type === "string" && type.startsWith("permission")) { debugLog(`event:${type}`, props); } if (type === "permission.asked" || type === "permission.updated") { if (recordAsk(props)) void explainAndNotify(client, props); } else if (type === "permission.replied") { recordReplied(props); } }; if (INJECT) { hooks["experimental.chat.system.transform"] = async (_input, output) => { const rule = buildSystemRule(); if (DEBUG) debugLog("system-rule", rule); output.system.push(rule); }; } if (ENFORCE) { hooks["tool.execute.before"] = async (input) => { if (!askedCallIDs.has(input.callID)) return; const ctx = await inspectCallContext(client, input.sessionID, input.callID); if (!ctx.found || ctx.explanation) return; askedCallIDs.delete(input.callID); throw new Error(EXPLAIN_FIRST_ERROR); }; } return hooks; };