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;
+};