From 5be32a6fe608b3d086da8c52d8a1765a69755254 Mon Sep 17 00:00:00 2001 From: m3ta-chiron Date: Fri, 25 Sep 2026 10:01:58 +0200 Subject: [PATCH] refactor: v2.0.0 als Verzeichnis-Package v2/index.js + v1/ eingefroren MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E2E-Befund vm-test (2.0.16): Einzeldatei-Plugin-Einträge werden mit 'configured plugin path must be a directory' verworfen — V2 verlangt Verzeichnis-Packages. Layout: v1/explain-permissions.js (1.3.0, Fleet-Pin, SHA unverändert f02a153d…) + v2/index.js (2.0.0, getesteter Stand fe2e78c0…). Event-Namen empirisch korrigiert: permission.asked/replied. --- explain-permissions/README.md | 55 ++- explain-permissions/v1/explain-permissions.js | 454 ++++++++++++++++++ .../{explain-permissions.js => v2/index.js} | 0 3 files changed, 490 insertions(+), 19 deletions(-) create mode 100644 explain-permissions/v1/explain-permissions.js rename explain-permissions/{explain-permissions.js => v2/index.js} (100%) diff --git a/explain-permissions/README.md b/explain-permissions/README.md index 9f05c52..c7b842f 100644 --- a/explain-permissions/README.md +++ b/explain-permissions/README.md @@ -2,9 +2,19 @@ Dieses Verzeichnis ist die **kanonische Quelle** für das opencode-Plugin `explain-permissions`. Es lebt im Sammel-Repos -[`opencode-plugins`](../README.md) und besteht aus genau einer Plugin-Datei -(`explain-permissions.js`) plus dieser Doku. Wer das Plugin nutzt, bezieht es -von hier — es gibt bewusst keine zweiten Solo-Kopien. +[`opencode-plugins`](../README.md) und besteht aus dieser Doku plus je einer +Plugin-Fassung pro Major-API — es gibt bewusst keine zweiten Solo-Kopien: + +| Pfad | Fassung | Status | +|---|---|---| +| `v1/explain-permissions.js` | 1.3.0 (V1-Plugin-API, Einzeldatei) | eingefroren — aktiver Fleet-Pin (ADR-0010) | +| `v2/index.js` | 2.0.0 (V2-Plugin-API, **Verzeichnis-Package**) | V2-only-Cut (az-fleet-7x8) — Pin aktiviert mit dem V2-Rollout | + +opencode V2 (verifiziert 2.0.16, vm-test) lädt **keine Einzeldatei-Plugin- +Einträge** — `file:///…/.js` wird mit der Log-Warnung +`configured plugin path must be a directory` verworfen. `v2/` ist deshalb +ein Verzeichnis-Package mit `index.js` als Entrypoint (kein `package.json` +erforderlich); ausgerollt wird das Verzeichnis als Ganzes. Konsumenten: @@ -35,11 +45,11 @@ Menschen ohne Computer-Kenntnisse. Das Plugin: ## Installation -### Lokale Kiste (Checkout + Symlink) +### Lokale Kiste (Checkout + Symlink, V1) ```bash git clone https://git.az-gruppe.com/AZ-Intec-GmbH/opencode-plugins.git ~/p/AZ/opencode-plugins -ln -sf ~/p/AZ/opencode-plugins/explain-permissions/explain-permissions.js ~/.config/opencode/plugins/explain-permissions.js +ln -sf ~/p/AZ/opencode-plugins/explain-permissions/v1/explain-permissions.js ~/.config/opencode/plugins/explain-permissions.js ``` Danach opencode neu starten (siehe unten). Wichtig: nur EINE Datei dieses @@ -47,20 +57,21 @@ Namens im Plugin-Ordner — sonst lädt opencode das Plugin doppelt. ### opencode V2 (ab v2.0.0 — V2-only) -Die Datei exportiert `Plugin.define({id: "az.explain-permissions", setup})` -als Default-Export und wird in `opencode.json(c)` unter dem V2-Schlüssel -`plugins` referenziert (Einzel-Datei-Eintrag, absolute `file:///`-URL): +`v2/index.js` exportiert `Plugin.define({id: "az.explain-permissions", setup})` +als Default-Export. Referenziert wird das **Verzeichnis** `v2/` (umgenannt +nach Wunsch, z. B. `explain-permissions-v2/`) unter dem V2-Schlüssel +`plugins` mit absoluter `file:///`-URL: ```jsonc { - "plugins": [{ "package": "file:///C:/ProgramData/opencode/plugins/explain-permissions.js" }] + "plugins": [{ "package": "file:///C:/ProgramData/opencode/plugins/explain-permissions-v2" }] } ``` -Alternativ läuft die Datei auch unverändert als Datei-Plugin in -`~/.config/opencode/plugins/` bzw. `.opencode/plugins/` (dort wird sie -automatisch entdeckt, kein Config-Eintrag nötig). Benötigt opencode V2 -(Core 2.0.x); V1 (1.18.x) lädt sie bewusst NICHT (V2-only-Cut) — die V1-Fleet +Alternativ läuft das Verzeichnis auch unverändert als Plugin-Package in +`~/.config/opencode/plugins/` bzw. `.opencode/plugins/` (dort automatisch +entdeckt, kein Config-Eintrag nötig). Benötigt opencode V2 (Core 2.0.x); +V1 (1.18.x) lädt es bewusst NICHT (V2-only-Cut) — die V1-Fleet bleibt auf der gepinnten v1.3.0. ### Fleet @@ -138,7 +149,7 @@ laden automatisch neu. | Config-Schlüssel `"plugin": ["file:///…"]` | `"plugins": [{"package": "file:///…"}]` (Einzel-Datei-Einträge unterstützt) | | `hooks.config` (liest permission-Sektion) | entfallen — Config-Dateien direkt per `fs` gelesen (Managed-Verzeichnis, Global, Projekt, `OPENCODE_CONFIG`), **beide** Formate: V1-`permission`-Map + V2-`permissions`-Array; Action-Renames bash→shell, task→subagent, write/patch→edit; `lsp`/`doom_loop` sind in V2 tote Actions und werden verworfen | | `permission.ask`-Hook + `permission.asked/updated`-Events | `ctx.permission.hook("evaluate")` — feuert für allow UND ask nach Regel-Evaluation, vor Dialog/Ausführung (explizites deny ruft den Hook nicht); Effect `ask` → `recordAsk` + `explainAndNotify` | -| `permission.replied`-Event | `permission.v2.replied` im öffentlichen Event-Stream (`ctx.event.subscribe`), Payload `{sessionID, requestID, reply}` | +| `permission.replied`-Event | `permission.replied` im öffentlichen Event-Stream (`ctx.event.subscribe`), Payload `{sessionID, requestID, reply}` — feuert nur bei echter Client-Antwort, NICHT bei Non-Interactive-Auto-Reject (vm-test 2.0.16) | | `experimental.chat.system.transform` | `ctx.session.hook("context")` → `event.system.push({type: "text", text})` — Agent-Loop inkl. Tool-Continuations | | `tool.execute.before` (ENFORCE) | `ctx.tool.hook("execute.before")` — Event `{tool, sessionID, callID, input}` | | `client.session.messages({path:{id}})` | `ctx.session.context({sessionID})` — Tool-Parts tragen die Call-ID als `id` (matcht `source.id` der Permission-Evaluation) | @@ -159,14 +170,20 @@ trotzdem aktiv, statt beim Laden der ganzen Datei zu werfen. - `explain-permissions/v2.0.0` — V2-only-Port auf die V2-Plugin-API (Core 2.0.x, az-fleet-7x8): `Plugin.define`-Default-Export, Hooks via `ctx.permission.hook("evaluate")` / `ctx.session.hook("context")` / - `ctx.tool.hook("execute.before")`, Event-Stream `permission.v2.asked` / - `permission.v2.replied`, Config-Lesung per `fs` mit V1+V2-Parser + `ctx.tool.hook("execute.before")`, Event-Stream `permission.asked` / + `permission.replied`, Config-Lesung per `fs` mit V1+V2-Parser (JSONC-tolerant) statt `hooks.config`, Action-Namen auf V2 - (bash→shell, task→subagent; lsp/doom_loop verworfen). KEIN V1-Export mehr - (V2-only-Cut, Entscheidung 25.09.) — V1-Fleet bleibt gepinnt auf v1.3.0. + (bash→shell, task→subagent; lsp/doom_loop verworfen). Layout: + **Verzeichnis-Package** `v2/index.js` — V2 (2.0.16) verwirft Einzeldatei- + Einträge („configured plugin path must be a directory", vm-test-Evidenz). + KEIN V1-Export mehr (V2-only-Cut, Entscheidung 25.09.) — V1-Fleet bleibt + gepinnt auf v1.3.0. Verifikation: Mock-ctx-Tests 21/21 grün (Config-Parsing beider Formen, System-Regel-Injektion, evaluate/ask-Pfad, Hook↔Event-Dedup, replied-Audit, - ENFORCE); E2E auf vm-test laut Issue az-fleet-7x8. + ENFORCE); E2E vm-test (2.0.16): Plugin-Load (plugin-loaded 2.0.0), + Config-Lesung Global+Managed+Projekt, System-Regel je Model-Request, + evaluate-Hook allow+ask mit V1-Config-Normalisierung (bash→shell), + ask-Audit + Notification-Pfad, Einzeldatei-Ablehnung reproduziert. - `explain-permissions/v1.3.0` — Erklärungs-Format überarbeitet: Zitat-Block (`>`) mit fetten Labels (`**WAS passiert:**` usw.), jeder Punkt auf eigener Zeile — bessere Lesbarkeit und visuelle Hervorhebung in TUI und Desktop diff --git a/explain-permissions/v1/explain-permissions.js b/explain-permissions/v1/explain-permissions.js new file mode 100644 index 0000000..7113cb0 --- /dev/null +++ b/explain-permissions/v1/explain-permissions.js @@ -0,0 +1,454 @@ +// explain-permissions.js — opencode plugin +// +// Kanonische Quelle: git.az-gruppe.com/AZ-Intec-GmbH/opencode-plugins +// (Tag explain-permissions/v1.3.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. Seit v1.3.0 mit festem Ausgabeformat: Zitat-Block +// (>) mit fetten Labels, jeder Punkt auf eigener Zeile — TUI und Desktop +// rendern das als klar abgesetzten Kasten mit farbigem Balken. +// 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.3.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. + +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.`; + +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]); +} + +// 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 }); +} + +// 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 + ? `${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 +// --------------------------------------------------------------------------- + +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; +}; diff --git a/explain-permissions/explain-permissions.js b/explain-permissions/v2/index.js similarity index 100% rename from explain-permissions/explain-permissions.js rename to explain-permissions/v2/index.js