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:
@@ -0,0 +1,674 @@
|
||||
// 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/v2.0.0). Fleet-Bezug: az-fleet vendort diese
|
||||
// Datei nach config/managed/plugins/ (Pin mit sha256, ADR-0010-Logik —
|
||||
// Byte-Gleichheit ist Vertragsgrundlage).
|
||||
//
|
||||
// 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).
|
||||
//
|
||||
// Zweck (unverändert): Jeden berechtigungspflichtigen Tool-Call laienverständlich
|
||||
// erklären — WAS passiert, WELCHE FOLGEN, WIE RISKANT (Zielgruppe:
|
||||
// nicht-technische Nutzer).
|
||||
//
|
||||
// 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.<id>.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
|
||||
// (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).
|
||||
//
|
||||
// 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 (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)
|
||||
// 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 = "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";
|
||||
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.`;
|
||||
|
||||
// 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
|
||||
// <server>_<tool> 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;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interner Zustand
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 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) {
|
||||
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 });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 parseConfigFile(file) {
|
||||
try {
|
||||
return JSON.parse(stripJsonc(fs.readFileSync(file, "utf8")));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// 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: { <action>: "ask"|"allow"|"deny" | { <pattern>: <action> } }
|
||||
function extractV1Permission(config, summary) {
|
||||
const permission = config?.permission;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
function buildSystemRule() {
|
||||
if (!permissionSummary) return SYSTEM_PROMPT_RULE;
|
||||
|
||||
const lines = [];
|
||||
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}
|
||||
|
||||
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")}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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") return false;
|
||||
const key = askKey(p);
|
||||
if (!key) return false;
|
||||
if (recordedAskKeys.has(key)) return false;
|
||||
recordedAskKeys.add(key);
|
||||
capSet(recordedAskKeys);
|
||||
|
||||
if (p.callID) askedCallIDs.set(p.callID, p.requestID);
|
||||
|
||||
appendLog({
|
||||
ts: new Date().toISOString(),
|
||||
event: "ask",
|
||||
permissionID: p.requestID,
|
||||
sessionID: p.sessionID,
|
||||
messageID: p.messageID,
|
||||
callID: p.callID,
|
||||
action: p.action,
|
||||
call: permissionCall(p),
|
||||
resources: Array.isArray(p.resources) ? p.resources : undefined,
|
||||
alwaysSuggestions: Array.isArray(p.save) ? p.save : 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 Message
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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(ctx, sessionID, callID, messageID) {
|
||||
try {
|
||||
const messages = await ctx.session.context({ sessionID });
|
||||
if (!Array.isArray(messages)) return { found: false };
|
||||
|
||||
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];
|
||||
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 };
|
||||
};
|
||||
|
||||
// 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 {
|
||||
return { found: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function explainAndNotify(ctx, p) {
|
||||
const rawCall = permissionCall(p);
|
||||
const type = p.action ?? "Tool";
|
||||
|
||||
let 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.requestID,
|
||||
callID: p.callID,
|
||||
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 (V2: Plugin.define + default-Export, Hooks via setup registriert)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
permissionSummary = readPermissionSummary(ctx?.location?.directory);
|
||||
if (DEBUG) debugLog("permission-summary", permissionSummary ?? "keine");
|
||||
|
||||
if (INJECT) {
|
||||
// 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);
|
||||
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) {
|
||||
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 () => 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;
|
||||
})();
|
||||
Reference in New Issue
Block a user