diff --git a/explain-permissions/README.md b/explain-permissions/README.md index 7de664b..9f05c52 100644 --- a/explain-permissions/README.md +++ b/explain-permissions/README.md @@ -45,9 +45,27 @@ ln -sf ~/p/AZ/opencode-plugins/explain-permissions/explain-permissions.js ~/.con Danach opencode neu starten (siehe unten). Wichtig: nur EINE Datei dieses 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): + +```jsonc +{ + "plugins": [{ "package": "file:///C:/ProgramData/opencode/plugins/explain-permissions.js" }] +} +``` + +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 +bleibt auf der gepinnten v1.3.0. + ### Fleet -Pin auf ein Tag (z. B. `explain-permissions/v1.1.0`) oder `main`; anonymes +Pin auf ein Tag (z. B. `explain-permissions/v2.0.0`) oder `main`; anonymes `git clone` über HTTPS reicht, kein Token nötig. ## Konfiguration via Environment-Variablen @@ -107,9 +125,48 @@ Relevante Events: **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. +Unter V2 gilt zusätzlich: Der Background-Service besitzt und cachet die +Konfiguration — Config-/Plugin-Änderungen wirken erst nach +`opencode service restart`; Änderungen an beobachteten Plugin-Verzeichnissen +laden automatisch neu. + +## V2-Migrationsmappe (v2.0.0, az-fleet-7x8) + +| V1 (1.18.x) | V2 (2.0.x) | +|---|---| +| `export const ExplainPermissionsPlugin = async ({client}) => …` (Hooks-Objekt) | `export default Plugin.define({id: "az.explain-permissions", async setup(ctx)})`, Hooks via `ctx.*.hook()` registriert | +| 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}` | +| `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) | +| `plugin-loaded`-Audit-Eintrag beim Laden | in `setup()` (jetzt mit Plugin-ID, opencode-Version, Location) | + +Unverändert übernommen: Notification-Logik je Plattform (PowerShell-WinRT-AUMID, +injektionssicher), JSONL-Audit-Log samt Env-Flags, fail-soft-Muster, das +Erklär-Format (Zitat-Block) und die ENFORCE-Semantik (greift nur auf Calls, +die durch einen Freigabe-Dialog gegangen sind). + +Der `@opencode/plugin`-Import ist bewusst dynamisch mit Fallback auf die +Rohestform `{id, setup}`: Schlägt die Paketauflösung fehl (Einzel-Datei-Package +außerhalb eines npm-Kontexts), bleibt der Fleet-Load fail-soft und das Plugin +trotzdem aktiv, statt beim Laden der ganzen Datei zu werfen. ## Versionierung +- `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 + (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. + 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. - `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/explain-permissions.js b/explain-permissions/explain-permissions.js index 7113cb0..77ac3b1 100644 --- a/explain-permissions/explain-permissions.js +++ b/explain-permissions/explain-permissions.js @@ -1,36 +1,52 @@ -// explain-permissions.js — opencode plugin +// explain-permissions.js — opencode V2-Plugin (Plugin-API v2, Core 2.0.x) // // 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). +// (Tag explain-permissions/v2.0.0). Fleet-Bezug: az-fleet vendort diese +// Datei nach config/managed/plugins/ (Pin mit sha256, ADR-0010-Logik — +// Byte-Gleichheit ist Vertragsgrundlage). // -// Zweck: Jeden berechtigungspflichtigen Tool-Call laienverständlich erklären — -// WAS passiert, WELCHE FOLGEN, WIE RISKANT (Zielgruppe: nicht-technische Nutzer). +// V2-only-Cut (v2.0.0, Entscheidung 25.09.): KEIN V1-/Dual-Support-Export +// mehr. Die V1-Fleet (1.18.x, gepinnt) lädt bewusst weiter explain-permissions +// v1.3.0; diese Datei wird erst mit dem atomaren V2-Rollout gepinnt und +// referenziert (ADR-0014: V2 lädt den Managed-Layer bis zur Behebung des +// Upstream-Bugs nicht — kein V2-Rollout vorher). // -// 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). +// Zweck (unverändert): Jeden berechtigungspflichtigen Tool-Call laienverständlich +// erklären — WAS passiert, WELCHE FOLGEN, WIE RISKANT (Zielgruppe: +// nicht-technische Nutzer). // -// Notifications je Plattform (v1.2.0): +// Was es tut (V2-Mechanik, Migrationsmappe az-fleet-7x8): +// 1. Config-Lese-Ersatz für den entfallenen config-Hook: Die Config-Dateien +// (Managed-Verzeichnis, Global, Projekt, OPENCODE_CONFIG) werden direkt +// per fs gelesen — BEIDE Regel-Formate: V1 permission-Map (String- und +// Objekt-Form) und V2 permissions-Array {action, resource, effect}. Die +// konkreten ask-/allow-/deny-Muster wandern verbindlich in den +// System-Prompt → das Modell weiß, welche Calls eine Freigabe brauchen, +// statt zu raten. (Agent-Level-Overrides in agents..permissions sieht +// dieser Summary nicht — im Zweifel erklärt das Modell dadurch eher zu +// viel als zu wenig.) +// 2. ctx.permission.hook("evaluate"): feuert für allow UND ask nach der +// Regel-Evaluation, VOR Dialog/Veröffentlichung/Ausführung (explizites +// deny ruft den Hook nicht — V2-Semantik). Bei effect==="ask": +// JSONL-Audit-Eintrag "ask" + Desktop-Notification mit der DEUTSCHEN +// ERKLÄRUNG des Modells (aus der Message vor dem Call extrahiert), +// Fallback: Roh-Call. +// 3. Event-Stream (ctx.event.subscribe): permission.v2.asked als redundante +// Quelle (dieselbe Anfrage wie der Hook — idempotent pro Call dedupliziert), +// permission.v2.replied loggt die Entscheidung (once/always/reject). +// 4. ctx.session.hook("context"): injiziert die Regel "Erst laienverständlich +// erklären (Was/Folgen/Risiko), dann der Call" in den System-Prompt — +// läuft für den Agent-Loop inklusive Tool-Continuations (= V1-Verhalten +// von experimental.chat.system.transform). Format seit v1.3.0: Zitat-Block +// (>) mit fetten Labels, jeder Punkt auf eigener Zeile — TUI und Desktop +// rendern das als klar abgesetzten Kasten mit farbigem Balken. +// 5. Optional (env OPENCODE_EXPLAIN_ENFORCE=1): ctx.tool.hook("execute.before") +// wirft einen Fehler, wenn das Modell einen freigabepflichtigen Call OHNE +// vorherige Text-Erklärung im selben Message-Turn absetzt. Default: AUS +// (Dialog-Schleifen). Wie in V1 greift ENFORCE nur auf Calls, die durch +// einen Freigabe-Dialog gegangen sind (ask-Pfad). +// +// Notifications je Plattform (unverändert seit v1.2.0): // Linux notify-send (libnotify) // macOS osascript "display notification" (Boardmittel) // Windows PowerShell-Toast via WinRT; AppUserModelID = PowerShell-AUMID @@ -40,11 +56,14 @@ // 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. +// V2-Neustart-Semantik: Änderungen an BEOBACHTETEN Config-Verzeichnissen +// laden Plugins automatisch neu; die Plugin-Datei selbst (ProgramData/verwaltete +// Ablage) sowie Config-Wirksamkeit generell erfordern `opencode service +// restart` (der Background-Service besitzt und cachet die Config — ADR-0014). +// Der Audit-Log-Eintrag "plugin-loaded" (mit Version) zeigt an, welche Version +// wann geladen wurde. // -// Konfiguration via Environment-Variablen: +// Konfiguration via Environment-Variablen (unverändert): // 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) @@ -65,7 +84,8 @@ import os from "node:os"; import { spawn } from "node:child_process"; const ENV = process.env; -const PLUGIN_VERSION = "1.3.0"; +const PLUGIN_VERSION = "2.0.0"; +const PLUGIN_ID = "az.explain-permissions"; const NOTIFY = ENV.OPENCODE_EXPLAIN_NOTIFY !== "0"; const LOG = ENV.OPENCODE_EXPLAIN_LOG !== "0"; const INJECT = ENV.OPENCODE_EXPLAIN_INJECT !== "0"; @@ -111,18 +131,13 @@ Regeln: 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", -]; +// V2-Action-Namen der Built-in-Tools (V2-Permissions-Doku): bash→shell, +// task→subagent; write/patch laufen unter edit. lsp/doom_loop sind KEINE V2- +// Core-Actions mehr und werden beim Parsen verworfen. MCP-Tools tragen +// _ als Action — unbekannte Namen lassen wir durch (die +// Summary listet nur, was konkret konfiguriert ist). +const V1_ACTION_ALIASES = { bash: "shell", task: "subagent", write: "edit", patch: "edit" }; +const DEAD_V1_ACTIONS = ["lsp", "doom_loop"]; let permissionSummary; @@ -130,8 +145,12 @@ let permissionSummary; // Interner Zustand // --------------------------------------------------------------------------- -const recordedPermissionIds = new Set(); -const askedCallIDs = new Map(); +// Dedup-Schlüssel: bevorzugt die Tool-Call-ID (der evaluate-Hook läuft VOR der +// Dialog-Veröffentlichung und kennt die Request-ID noch nicht; das +// permission.v2.asked-Event liefert beide). Fallback: Request-ID, sonst eine +// strukturelle Beschreibung der Anfrage. +const recordedAskKeys = new Set(); +const askedCallIDs = new Map(); // callID → requestID (für ENFORCE) function capSet(set, max = 1000) { if (set.size > max) { @@ -229,47 +248,151 @@ function debugLog(source, 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; +// --------------------------------------------------------------------------- +// Config-Lesung (Ersatz für den entfallenen V1-config-Hook) +// --------------------------------------------------------------------------- + +// JSONC-tolerantes Strippen: Zeilen-/Blockkommentare und abschließende Kommas +// NUR außerhalb von String-Literalen entfernen (Zustandsautomat über das +// Zeichenfenster — kein Regex, der Strings zerstören könnte). Best effort: +// Parse-Fehler fallen im Caller still durch (fail-soft, Summary bleibt leer). +function stripJsonc(text) { + let out = ""; + let i = 0; + let inString = false; + while (i < text.length) { + const c = text[i]; + const next = text[i + 1]; + if (inString) { + out += c; + if (c === "\\") { + if (i + 1 < text.length) out += next; + i += 2; + continue; + } + if (c === '"') inString = false; + i += 1; + continue; + } + if (c === '"') { + inString = true; + out += c; + i += 1; + continue; + } + if (c === "/" && next === "/") { + while (i < text.length && text[i] !== "\n") i += 1; + continue; + } + if (c === "/" && next === "*") { + i += 2; + while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i += 1; + i += 2; + continue; + } + out += c; + i += 1; + } + // Abschließende Kommas vor } oder ] entfernen (außerhalb von Strings — nach + // dem Kommentar-Strip sicher, weil nur noch ",}" / ",]"-Muster mit optionalen + // Whitespaces übriggs bleiben können). + return out.replace(/,(\s*[}\]])/g, "$1"); } -function permissionCall(p) { - return ( - p.metadata?.command ?? - (Array.isArray(p.patterns) ? p.patterns.join(" | ") : undefined) ?? - p.title ?? - "unbekannter Call" - ); +function parseConfigFile(file) { + try { + return JSON.parse(stripJsonc(fs.readFileSync(file, "utf8"))); + } catch { + return undefined; + } } -// 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) { +// Systemweites Managed-Verzeichnis (V1-Fleet-Anker; V2 lädt es Stand 2.0.16 +// nicht — siehe ADR-0014 —, aber lesen schadet nicht und greift, sobald der +// Upstream-Bug behoben ist): Windows %ProgramData%\opencode, sonst die +// plattformüblichen Pendants. +function managedConfigDir() { + if (process.platform === "win32") { + return path.join(ENV.ProgramData || "C:\\ProgramData", "opencode"); + } + if (process.platform === "darwin") { + return "/Library/Application Support/opencode"; + } + return "/etc/opencode"; +} + +// Kandidaten in Ladereihenfolge niedrig→hoch; innerhalb eines Verzeichnisses +// gilt jsonc-vor-json (json lädt später und gewinnt). Spätere Dateien hängen +// ihre Regeln an → approximiert „letzte passende Regel gewinnt“ über Layer. +function configCandidates(projectDir) { + const files = []; + const pushDir = (dir) => { + if (!dir) return; + files.push(path.join(dir, "opencode.jsonc"), path.join(dir, "opencode.json")); + }; + pushDir(managedConfigDir()); + pushDir(path.join(os.homedir(), ".config", "opencode")); + if (projectDir) { + pushDir(projectDir); + pushDir(path.join(projectDir, ".opencode")); + } + if (ENV.OPENCODE_CONFIG) files.push(ENV.OPENCODE_CONFIG); + return files; +} + +// V1-Action-Key normalisieren; null = verwerfen (in V2 tote Actions). +function normalizeAction(key) { + if (typeof key !== "string" || !key) return null; + const action = V1_ACTION_ALIASES[key] ?? key; + return DEAD_V1_ACTIONS.includes(key) ? null : action; +} + +function addRule(summary, action, pattern, effect) { + if (!action || typeof pattern !== "string" || !pattern) return; + if (effect !== "ask" && effect !== "allow" && effect !== "deny") return; + const entry = (summary[action] ??= { asks: [], allows: [], denys: [] }); + const list = effect === "ask" ? entry.asks : effect === "allow" ? entry.allows : entry.denys; + // Layer-Merge kann dasselbe Muster mehrfach liefern (Global + Projekt) — + // Reihenfolge erhalten, Dublette überspringen. + if (!list.includes(pattern)) list.push(pattern); +} + +// V1-Form: permission: { : "ask"|"allow"|"deny" | { : } } +function extractV1Permission(config, summary) { 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 (!permission || typeof permission !== "object" || Array.isArray(permission)) return; + for (const [key, rule] of Object.entries(permission)) { + const action = normalizeAction(key); + if (!action) continue; + if (typeof rule === "string") { + addRule(summary, action, "*", rule); + } else if (rule && typeof rule === "object") { + for (const [pattern, effect] of Object.entries(rule)) { + if (typeof effect === "string") addRule(summary, action, pattern, effect); } } - if (asks.length || allows.length) summary[key] = { asks, allows }; + } +} + +// V2-Form: permissions: [ { action, resource, effect } ] (geordnetes Array) +function extractV2Permissions(config, summary) { + const permissions = config?.permissions; + if (!Array.isArray(permissions)) return; + for (const rule of permissions) { + if (!rule || typeof rule !== "object") continue; + const action = normalizeAction(rule.action); + if (action) addRule(summary, action, rule.resource ?? "*", rule.effect); + } +} + +function readPermissionSummary(projectDir) { + const summary = {}; + for (const file of configCandidates(projectDir)) { + const config = parseConfigFile(file); + if (!config) continue; + if (DEBUG) debugLog(`config:${file}`, { permission: config.permission ?? null, permissions: config.permissions ?? null }); + extractV1Permission(config, summary); + extractV2Permissions(config, summary); } return Object.keys(summary).length ? summary : undefined; } @@ -278,13 +401,17 @@ function buildSystemRule() { if (!permissionSummary) return SYSTEM_PROMPT_RULE; const lines = []; - for (const [key, { asks, allows }] of Object.entries(permissionSummary)) { - let line = `- ${key}: `; + for (const [action, { asks, allows, denys }] of Object.entries(permissionSummary)) { + let line = `- ${action}: `; 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(", "); } + if (denys.length) { + line += (asks.length || allows.length ? " | blockiert: " : "blockiert: "); + line += denys.join(", "); + } lines.push(line); } return `${SYSTEM_PROMPT_RULE} @@ -293,31 +420,56 @@ Diese Muster lösen laut opencode-Konfiguration eine Freigabe aus. Die Liste ist ${lines.join("\n")}`; } +// --------------------------------------------------------------------------- +// Ask-/Reply-Protokollierung +// --------------------------------------------------------------------------- + +// Vereinheitlichte Anfrage aus den beiden Quellen (evaluate-Hook, +// permission.v2.asked-Event). Felder sind optional; callID ist die Tool-Call-ID +// aus source (V2: source.id), requestID die Permission-Request-ID. +function permissionCall(p) { + return ( + p.metadata?.command ?? + (Array.isArray(p.resources) ? p.resources.join(" | ") : undefined) ?? + p.title ?? + "unbekannter Call" + ); +} + +function askKey(p) { + return ( + p.callID ?? + p.requestID ?? + (p.sessionID ? `${p.sessionID}:${p.action}:${Array.isArray(p.resources) ? p.resources.join("|") : ""}` : undefined) + ); +} + /** - * 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. + * Freigabe-Anfrage protokollieren. Idempotent pro Tool-Call (evaluate-Hook und + * permission.v2.asked liefern dieselbe Anfrage). Liefert true nur bei der + * ersten Aufzeichnung dieses Calls. */ 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); + if (!p || typeof p !== "object") return false; + const key = askKey(p); + if (!key) return false; + if (recordedAskKeys.has(key)) return false; + recordedAskKeys.add(key); + capSet(recordedAskKeys); - const callID = p.tool?.callID ?? p.callID; - if (callID) askedCallIDs.set(callID, p.id); + if (p.callID) askedCallIDs.set(p.callID, p.requestID); appendLog({ ts: new Date().toISOString(), event: "ask", - permissionID: p.id, + permissionID: p.requestID, sessionID: p.sessionID, - messageID: p.tool?.messageID ?? p.messageID, - callID, - type: permissionType(p), + messageID: p.messageID, + callID: p.callID, + action: p.action, call: permissionCall(p), - patterns: Array.isArray(p.patterns) ? p.patterns : undefined, - alwaysSuggestions: Array.isArray(p.always) ? p.always : undefined, + resources: Array.isArray(p.resources) ? p.resources : undefined, + alwaysSuggestions: Array.isArray(p.save) ? p.save : undefined, }); return true; @@ -334,30 +486,30 @@ function recordReplied(props) { } // --------------------------------------------------------------------------- -// Message-Kontext: Text-Parts VOR dem Tool-Part derselben Assistant-Message +// Message-Kontext: Text-Parts VOR dem Tool-Part derselben 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). + * Den Message-Turn untersuchen, der den Tool-Call enthält. + * V2: ctx.session.context({sessionID}) liefert die Messages; Assistant-Parts + * sind u. a. {type:"text", text} und {type:"tool", id, name, state} — die + * Tool-Call-ID des Permission-Source (source.id) matcht die Part-ID. + * Liefert { found: true, explanation } mit den Text-Parts vor dem Tool-Part, + * { found: true } ohne Erklärung, oder { found: false }, wenn Call oder API + * nicht verfügbar sind — Caller behandeln found:false als „niemals blocken, + * nie anreichern" (fail-open). */ -async function inspectCallContext(client, sessionID, callID) { +async function inspectCallContext(ctx, sessionID, callID, messageID) { try { - const result = await client.session.messages({ path: { id: sessionID } }); - const messages = result?.data ?? result; + const messages = await ctx.session.context({ sessionID }); 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 isToolMatch = (part) => + part?.type === "tool" && (part.id === callID || part.callID === callID); + const scan = (parts) => { + const toolIndex = parts.findIndex(isToolMatch); + if (toolIndex === -1) return undefined; const texts = []; for (let i = 0; i < toolIndex; i++) { const part = parts[i]; @@ -366,6 +518,21 @@ async function inspectCallContext(client, sessionID, callID) { } } return { found: true, explanation: texts.length ? texts.join("\n") : undefined }; + }; + + // Bevorzugt die Message aus dem Permission-Source (messageID), Fallback: + // alle Messages scannen. + if (messageID) { + const msg = messages.find((m) => m?.id === messageID); + if (Array.isArray(msg?.parts)) { + const hit = scan(msg.parts); + if (hit) return hit; + } + } + for (const msg of messages) { + if (!Array.isArray(msg?.parts)) continue; + const hit = scan(msg.parts); + if (hit) return hit; } return { found: false }; } catch { @@ -373,22 +540,22 @@ async function inspectCallContext(client, sessionID, callID) { } } -async function explainAndNotify(client, p) { - const callID = p.tool?.callID ?? p.callID; +async function explainAndNotify(ctx, p) { const rawCall = permissionCall(p); - const type = permissionType(p) ?? "Tool"; + const type = p.action ?? "Tool"; let explanation; - if (callID) { - const ctx = await inspectCallContext(client, p.sessionID, callID); - explanation = ctx.explanation; + if (p.callID) { + const result = await inspectCallContext(ctx, p.sessionID, p.callID, p.messageID); + explanation = result.explanation; } if (explanation) { appendLog({ ts: new Date().toISOString(), event: "ask.explained", - permissionID: p.id, + permissionID: p.requestID, + callID: p.callID, explanation: explanation.slice(0, 2000), }); } @@ -400,55 +567,108 @@ async function explainAndNotify(client, p) { } // --------------------------------------------------------------------------- -// Plugin +// Plugin (V2: Plugin.define + default-Export, Hooks via setup registriert) // --------------------------------------------------------------------------- -export const ExplainPermissionsPlugin = async ({ client }) => { - const hooks = {}; +async function setup(ctx) { + appendLog({ + ts: new Date().toISOString(), + event: "plugin-loaded", + version: PLUGIN_VERSION, + id: PLUGIN_ID, + opencode: ctx?.app?.version, + directory: ctx?.location?.directory, + }); - 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); - } - }; + permissionSummary = readPermissionSummary(ctx?.location?.directory); + if (DEBUG) debugLog("permission-summary", permissionSummary ?? "keine"); if (INJECT) { - hooks["experimental.chat.system.transform"] = async (_input, output) => { + // Agent-Loop inkl. Tool-Continuations (= V1 experimental.chat.system.transform) + await ctx.session.hook("context", (event) => { const rule = buildSystemRule(); if (DEBUG) debugLog("system-rule", rule); - output.system.push(rule); - }; + event.system.push({ type: "text", text: rule }); + }); } + // Feuert für allow UND ask nach Regel-Evaluation, vor Dialog/Ausführung; + // explizites deny ruft den Hook nicht. Beobachter-Status: ändert effect nie. + await ctx.permission.hook("evaluate", (event) => { + debugLog("permission.evaluate", event); + if (event?.effect !== "ask") return; + const rec = { + sessionID: event.sessionID, + action: event.action, + resources: event.resources, + metadata: event.metadata, + messageID: event.source?.messageID, + callID: event.source?.id ?? event.source?.callID, + }; + if (recordAsk(rec)) void explainAndNotify(ctx, rec); + }); + + // Event-Stream: asked als redundante Quelle (Dedup über die Call-ID), + // replied für das Audit der Entscheidung. Envelope tolerant lesen + // (properties || data || flach), Abruch über den Cleanup-Return. + const controller = new AbortController(); + void (async () => { + try { + for await (const ev of ctx.event.subscribe({ signal: controller.signal })) { + const type = ev?.type; + const props = ev?.properties ?? ev?.data ?? ev; + if (DEBUG && typeof type === "string" && type.startsWith("permission")) { + debugLog(`event:${type}`, props); + } + if (type === "permission.v2.asked") { + const rec = { + requestID: props?.id, + sessionID: props?.sessionID, + action: props?.action, + resources: props?.resources, + metadata: props?.metadata, + save: props?.save, + messageID: props?.source?.messageID, + callID: props?.source?.id ?? props?.source?.callID, + }; + if (recordAsk(rec)) void explainAndNotify(ctx, rec); + } else if (type === "permission.v2.replied") { + recordReplied(props); + } + } + } catch { + // Stream-Abbruch (Cleanup) oder Transportfehler → stiller No-Op + } + })(); + 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); + await ctx.tool.hook("execute.before", async (event) => { + const callID = event?.callID ?? event?.id; + if (!callID || !askedCallIDs.has(callID)) return; + const result = await inspectCallContext(ctx, event.sessionID, callID, undefined); + if (!result.found || result.explanation) return; + askedCallIDs.delete(callID); throw new Error(EXPLAIN_FIRST_ERROR); - }; + }); } - return hooks; -}; + return () => controller.abort(); +} + +// Plugin.define ist die getypte Identitätsfunktion der V2-API. Der Import wird +// bewusst DYNAMISCH gehalten mit Fallback auf die Rohestform {id, setup}: +// Schlägt die Paketauflösung fehl (z. B. Einzel-Datei-Package außerhalb eines +// npm-Kontexts), wirf der statische Import das Laden der GESAMTEN Datei — +// dynamisch bleibt der Fleet-Load fail-soft und das Plugin trotzdem aktiv. +const definition = { id: PLUGIN_ID, setup }; + +export default await (async () => { + try { + const mod = await import("@opencode/plugin"); + const define = mod?.Plugin?.define; + if (typeof define === "function") return define(definition); + } catch { + // Fallback unten + } + return definition; +})();