refactor: v2.0.0 als Verzeichnis-Package v2/index.js + v1/ eingefroren

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.
This commit is contained in:
m3ta-chiron
2026-09-25 10:01:58 +02:00
parent cbc1cb739f
commit 5be32a6fe6
3 changed files with 490 additions and 19 deletions
+36 -19
View File
@@ -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:///…/<datei>.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
@@ -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('<toast><visual><binding template=\"ToastGeneric\"><text>' + $t + '</text><text>' + $b + '</text></binding></visual></toast>')",
"$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;
};