|
|
@@ -1,5 +1,10 @@
|
|
|
|
// explain-permissions.js — opencode plugin
|
|
|
|
// 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 —
|
|
|
|
// Zweck: Jeden berechtigungspflichtigen Tool-Call laienverständlich erklären —
|
|
|
|
// WAS passiert, WELCHE FOLGEN, WIE RISKANT (Zielgruppe: nicht-technische Nutzer).
|
|
|
|
// WAS passiert, WELCHE FOLGEN, WIE RISKANT (Zielgruppe: nicht-technische Nutzer).
|
|
|
|
//
|
|
|
|
//
|
|
|
@@ -18,11 +23,23 @@
|
|
|
|
// 3. experimental.chat.system.transform:
|
|
|
|
// 3. experimental.chat.system.transform:
|
|
|
|
// → injiziert die Regel "Erst laienverständlich erklären (Was/Folgen/Risiko),
|
|
|
|
// → injiziert die Regel "Erst laienverständlich erklären (Was/Folgen/Risiko),
|
|
|
|
// dann der Call" in den System-Prompt — in jeder Session, auch in Worktrees
|
|
|
|
// dann der Call" in den System-Prompt — in jeder Session, auch in Worktrees
|
|
|
|
// ohne eigenes AGENTS.md
|
|
|
|
// 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
|
|
|
|
// 4. Optional (env OPENCODE_EXPLAIN_ENFORCE=1): tool.execute.before wirft einen
|
|
|
|
// Fehler, wenn das Modell einen berechtigungspflichtigen Call OHNE vorherige
|
|
|
|
// Fehler, wenn das Modell einen berechtigungspflichtigen Call OHNE vorherige
|
|
|
|
// Text-Erklärung im selben Message-Turn absetzt. Default: AUS (Dialog-Schleifen).
|
|
|
|
// 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
|
|
|
|
// Wichtig: Plugin-Änderungen greifen erst nach NEUSTART von opencode — eine neue
|
|
|
|
// Chat-Session im laufenden Prozess reicht nicht. Der Audit-Log-Eintrag
|
|
|
|
// Chat-Session im laufenden Prozess reicht nicht. Der Audit-Log-Eintrag
|
|
|
|
// "plugin-loaded" (mit Version) zeigt an, welche Version wann geladen wurde.
|
|
|
|
// "plugin-loaded" (mit Version) zeigt an, welche Version wann geladen wurde.
|
|
|
@@ -34,7 +51,10 @@
|
|
|
|
// OPENCODE_EXPLAIN_ENFORCE=1 Hartes Erzwingen aktivieren (Default: aus)
|
|
|
|
// OPENCODE_EXPLAIN_ENFORCE=1 Hartes Erzwingen aktivieren (Default: aus)
|
|
|
|
// OPENCODE_EXPLAIN_DEBUG=1 Permission-Events ins Log (Default: aus)
|
|
|
|
// OPENCODE_EXPLAIN_DEBUG=1 Permission-Events ins Log (Default: aus)
|
|
|
|
// OPENCODE_EXPLAIN_LOG_PATH=… Anderer Log-Pfad
|
|
|
|
// OPENCODE_EXPLAIN_LOG_PATH=… Anderer Log-Pfad
|
|
|
|
// (Default: ~/.local/state/opencode/explain-permissions.jsonl)
|
|
|
|
// (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:
|
|
|
|
// Log ansehen:
|
|
|
|
// tail -f ~/.local/state/opencode/explain-permissions.jsonl | jq .
|
|
|
|
// tail -f ~/.local/state/opencode/explain-permissions.jsonl | jq .
|
|
|
@@ -45,33 +65,51 @@ import os from "node:os";
|
|
|
|
import { spawn } from "node:child_process";
|
|
|
|
import { spawn } from "node:child_process";
|
|
|
|
|
|
|
|
|
|
|
|
const ENV = process.env;
|
|
|
|
const ENV = process.env;
|
|
|
|
const PLUGIN_VERSION = "1.1.0";
|
|
|
|
const PLUGIN_VERSION = "1.3.0";
|
|
|
|
const NOTIFY = ENV.OPENCODE_EXPLAIN_NOTIFY !== "0";
|
|
|
|
const NOTIFY = ENV.OPENCODE_EXPLAIN_NOTIFY !== "0";
|
|
|
|
const LOG = ENV.OPENCODE_EXPLAIN_LOG !== "0";
|
|
|
|
const LOG = ENV.OPENCODE_EXPLAIN_LOG !== "0";
|
|
|
|
const INJECT = ENV.OPENCODE_EXPLAIN_INJECT !== "0";
|
|
|
|
const INJECT = ENV.OPENCODE_EXPLAIN_INJECT !== "0";
|
|
|
|
const ENFORCE = ENV.OPENCODE_EXPLAIN_ENFORCE === "1";
|
|
|
|
const ENFORCE = ENV.OPENCODE_EXPLAIN_ENFORCE === "1";
|
|
|
|
const DEBUG = ENV.OPENCODE_EXPLAIN_DEBUG === "1";
|
|
|
|
const DEBUG = ENV.OPENCODE_EXPLAIN_DEBUG === "1";
|
|
|
|
const LOG_PATH =
|
|
|
|
|
|
|
|
ENV.OPENCODE_EXPLAIN_LOG_PATH ||
|
|
|
|
// Windows: %LOCALAPPDATA% (idiomatisch, roamt nicht); sonst XDG-State im Profil.
|
|
|
|
path.join(os.homedir(), ".local", "state", "opencode", "explain-permissions.jsonl");
|
|
|
|
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)
|
|
|
|
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:
|
|
|
|
Vor JEDEM Tool-Call, der eine Freigabe erfordert, schreibe unmittelbar davor eine Erklärung auf Deutsch, die ein Mensch ohne Computer-Kenntnisse versteht.
|
|
|
|
- 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."
|
|
|
|
Format — GENAU SO einhalten (dient der Lesbarkeit und Hebung im Chat):
|
|
|
|
Beispiel (schlecht, zu technisch): „Ich führe rm -rf /tmp/foo aus."
|
|
|
|
- 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:
|
|
|
|
Regeln:
|
|
|
|
- Der genaue Befehl darf NACH der Erklärung zusätzlich genannt werden, die Erklärung muss aber auch ohne ihn verständlich sein
|
|
|
|
- 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
|
|
|
|
- Kein Fachjargon (rm, rekursiv, force, Pipe, Exit-Code) ohne Umschreibung
|
|
|
|
- Erst die Erklärung, dann der Call — niemals umgekehrt
|
|
|
|
- Erst die Erklärung, dann der Call — niemals umgekehrt
|
|
|
|
- Rein lesende, automatisch erlaubte Standard-Calls brauchen keine Extra-Erklärung
|
|
|
|
- Rein lesende, automatisch erlaubte Standard-Calls brauchen keine Extra-Erklärung
|
|
|
|
- Im Zweifel, ob ein Call eine Freigabe braucht: immer erklären`;
|
|
|
|
- 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 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 = [
|
|
|
|
const PERMISSION_KEYS = [
|
|
|
|
"bash",
|
|
|
|
"bash",
|
|
|
@@ -118,20 +156,68 @@ function appendLog(entry) {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function notify(title, body) {
|
|
|
|
// Detached Spawn, fail-soft in Node UND Bun: ENOENT wirft/emittiert in den
|
|
|
|
if (!NOTIFY) return;
|
|
|
|
// 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 {
|
|
|
|
try {
|
|
|
|
const child = spawn(
|
|
|
|
const child = spawn(cmd, args, { detached: true, stdio: "ignore", env: { ...ENV, ...env } });
|
|
|
|
"notify-send",
|
|
|
|
child.on("error", () => {});
|
|
|
|
["-a", "opencode", "-i", "utilities-terminal", "-u", "normal", title, body.slice(0, 400)],
|
|
|
|
|
|
|
|
{ detached: true, stdio: "ignore" },
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
child.unref();
|
|
|
|
child.unref();
|
|
|
|
} catch {
|
|
|
|
} catch {
|
|
|
|
// keine Desktop-Session (headless) → egal
|
|
|
|
// 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) {
|
|
|
|
function debugLog(source, payload) {
|
|
|
|
if (!DEBUG) return;
|
|
|
|
if (!DEBUG) return;
|
|
|
|
let serialized;
|
|
|
|
let serialized;
|
|
|
@@ -308,7 +394,7 @@ async function explainAndNotify(client, p) {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const body = explanation
|
|
|
|
const body = explanation
|
|
|
|
? `${explanation}\n—\n${type}: ${rawCall}\n→ im opencode-UI antworten`
|
|
|
|
? `${plainText(explanation)}\n—\n${type}: ${rawCall}\n→ im opencode-UI antworten`
|
|
|
|
: `${rawCall}\n→ im opencode-UI antworten`;
|
|
|
|
: `${rawCall}\n→ im opencode-UI antworten`;
|
|
|
|
notify(`opencode · Freigabe nötig (${type})`, body);
|
|
|
|
notify(`opencode · Freigabe nötig (${type})`, body);
|
|
|
|
}
|
|
|
|
}
|
|
|
|