From 321a3f91b82d435294da5eb9ea7987021c6c16b3 Mon Sep 17 00:00:00 2001 From: m3tam3re Date: Thu, 17 Sep 2026 15:15:21 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20explain-permissions=20v1.1.0=20?= =?UTF-8?q?=E2=80=94=20kanonische=20Quelle=20im=20Sammel-Repos=20(az-fleet?= =?UTF-8?q?-imn)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Erstes Plugin im neuen Sammel-Repos opencode-plugins: Baseline-Import der Solo-Datei von der lokalen Linux-Kiste (SHA-256-verifiziert identisch) plus README mit Zweck, Env-Variablen, Audit-Log-Anzeige und Neustart-Hinweis. Repo-Struktur: ein Ordner pro Plugin, pro-Plugin-Tags /v. --- README.md | 47 +++ explain-permissions/README.md | 83 +++++ explain-permissions/explain-permissions.js | 368 +++++++++++++++++++++ 3 files changed, 498 insertions(+) create mode 100644 README.md create mode 100644 explain-permissions/README.md create mode 100644 explain-permissions/explain-permissions.js diff --git a/README.md b/README.md new file mode 100644 index 0000000..95bc520 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# opencode-plugins — kanonische Plugin-Sammlung für opencode + +Dieses Repo ist die **eine Wahrheitsquelle** für opencode-Plugins der AZ-Gruppe. +Es ist ein reines Content-Repo: hier leben nur die Plugin-Dateien plus ihre +Doku — die Technik, die sie ausliefert, gehört in das Fleet-Repo `az-fleet`. + +Das Repos ist **anonym lesbar** (kein Token für Pulls nötig), Schreiben läuft +über die Organisation AZ-Intec-GmbH. + +## Enthaltene Plugins + +| Plugin | Zweck | Version | +|---|---|---| +| [`explain-permissions/`](explain-permissions/) | Erklärt jeden freigabepflichtigen Tool-Call laienverständlich (Was/Folgen/Risiko), Desktop-Notification + Audit-Log | v1.1.0 | + +## Struktur & Konventionen + +``` +opencode-plugins/ +└── / + ├── .js # die Plugin-Datei (genau eine pro Plugin) + └── README.md # Doku: Zweck, Env-Variablen, Logs, Neustart-Hinweis +``` + +- **Ein Ordner pro Plugin**, Name = Plugin-Name (kebab-case, keine Umlaute). +- Version steht in `PLUGIN_VERSION` im Dateikopf des Plugins. +- **Tags sind pro Plugin benamst**: `/v` — z. B. + `explain-permissions/v1.1.0`. So kollidieren Versionen verschiedener Plugins + nie. Ein Tag markiert immer exakt den Plugin-Stand bei Freigabe. +- Neue Plugins: Ordner anlegen, in der Tabelle oben eintragen, Commit + Tag. + +## Bezug + +### Lokale Workstation (Checkout + Symlink) + +```bash +git clone https://git.az-gruppe.com/AZ-Intec-GmbH/opencode-plugins.git ~/p/AZ/opencode-plugins +ln -sf ~/p/AZ/opencode-plugins//.js ~/.config/opencode/plugins/.js +``` + +Danach opencode **neu starten**. Wichtig: nur EINE Datei dieses Namens im +Plugin-Ordner — sonst lädt opencode das Plugin doppelt. + +### Fleet (`az-fleet`) + +Pin auf einen Tag (`/v`) oder `main`; anonymes +`git clone` über HTTPS reicht. diff --git a/explain-permissions/README.md b/explain-permissions/README.md new file mode 100644 index 0000000..84670ed --- /dev/null +++ b/explain-permissions/README.md @@ -0,0 +1,83 @@ +# explain-permissions — opencode-Plugin für laienverständliche Tool-Call-Erklärungen + +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. + +Konsumenten: + +| Konsument | Bezug | +|---|---| +| Lokale Linux-Kiste (m3tam3re) | Repo-Checkout + Symlink in `~/.config/opencode/plugins/` | +| Fleet (`az-fleet`) | später per Repo-Pin (anonym lesbar, kein Token für Pulls) | + +## Zweck + +Jeden berechtigungspflichtigen Tool-Call laienverständlich erklären — für +Menschen ohne Computer-Kenntnisse. Das Plugin: + +1. **Injiziert die Freigabe-Regel in den System-Prompt** (Was / Welche Folgen / + Wie riskant, auf Deutsch, vor jedem freigabepflichtigen Call) — inklusive + der konkreten ask-/allow-Muster aus der opencode-Konfiguration. +2. **Schickt eine Desktop-Notification**, wenn der Freigabe-Dialog erscheint — + mit der deutschen Erklärung des Modells, Fallback: der Roh-Befehl. +3. **Schreibt ein JSONL-Audit-Log**: jeden Freigabe-Request (`ask`), die + abgegebene Erklärung (`ask.explained`) und die Entscheidung (`replied`). +4. **Erzwingt optional** (Default: aus), dass Erklärungen VOR dem Call kommen + — sonst wirft der Call einen Fehler. + +## Installation + +### Lokale Kiste (Checkout + Symlink) + +```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 +``` + +Danach opencode neu starten (siehe unten). Wichtig: nur EINE Datei dieses +Namens im Plugin-Ordner — sonst lädt opencode das Plugin doppelt. + +### Fleet + +Pin auf ein Tag (z. B. `explain-permissions/v1.1.0`) oder `main`; anonymes +`git clone` über HTTPS reicht, kein Token nötig. + +## Konfiguration via Environment-Variablen + +| Variable | Wirkung | Default | +|---|---|---| +| `OPENCODE_EXPLAIN_NOTIFY=0` | Desktop-Notifications abschalten | an | +| `OPENCODE_EXPLAIN_LOG=0` | Audit-Log abschalten | an | +| `OPENCODE_EXPLAIN_INJECT=0` | System-Prompt-Regel abschalten | an | +| `OPENCODE_EXPLAIN_ENFORCE=1` | Hartes Erzwingen der Erklärung aktivieren (Call ohne vorherige Erklärung → Fehler) | aus | +| `OPENCODE_EXPLAIN_DEBUG=1` | Permission-Events ins Log schreiben | aus | +| `OPENCODE_EXPLAIN_LOG_PATH=…` | Anderer Log-Pfad | `~/.local/state/opencode/explain-permissions.jsonl` | + +## Audit-Log ansehen + +```bash +tail -f ~/.local/state/opencode/explain-permissions.jsonl | jq . +``` + +Relevante Events: + +- `plugin-loaded` — mit `version`: welche Plugin-Version wann geladen wurde +- `ask` — Freigabe-Request (Roh-Call, Muster, Session) +- `ask.explained` — die Erklärung, die das Modell vor dem Call abgegeben hat +- `replied` — die Entscheidung (`once` / `always` / `reject`) + +## Wichtig: Neustart + +**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. + +## Versionierung + +- `explain-permissions/v1.1.0` — Baseline, exakt der Stand der lokalen + Solo-Datei vom 17.09.2026 (Inhaltsgleichheit per SHA-256 verifiziert). +- Fortlaufende Versionsnummer in `PLUGIN_VERSION` im Dateikopf; Änderungen + bekommen ein Tag `explain-permissions/v` in diesem Repo. diff --git a/explain-permissions/explain-permissions.js b/explain-permissions/explain-permissions.js new file mode 100644 index 0000000..253ab84 --- /dev/null +++ b/explain-permissions/explain-permissions.js @@ -0,0 +1,368 @@ +// explain-permissions.js — opencode plugin +// +// 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). +// +// 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: ~/.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.1.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"; +const LOG_PATH = + ENV.OPENCODE_EXPLAIN_LOG_PATH || + path.join(os.homedir(), ".local", "state", "opencode", "explain-permissions.jsonl"); + +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 + } +} + +function notify(title, body) { + if (!NOTIFY) return; + try { + const child = spawn( + "notify-send", + ["-a", "opencode", "-i", "utilities-terminal", "-u", "normal", title, body.slice(0, 400)], + { detached: true, stdio: "ignore" }, + ); + child.unref(); + } catch { + // keine Desktop-Session (headless) → egal + } +} + +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; +};