Author SHA1 Message Date
m3ta-chiron a9ea4361cb fix: E2E-Befunde vm-test 2.0.16 eingearbeitet (az-fleet-7x8)
- Message-Teile liegen im Feld content (nicht parts — SDK-Typen sagen
  parts; beide werden gelesen)
- Persistenz-Lag: Tool-Part ist zur Evaluations-Zeit ggf. noch nicht im
  Message-Kontext — Text-Parts der Call-Message gelten dann als Erklärung
  (liegen per Definition vor dem Call)
- execute.before-Event: Tool-Call-ID im Feld id (callID-Fallback bleibt)
  plus messageID/agent — Shape per Diagnose-Plugin verifiziert
- Event-Namen im Stream: permission.asked/replied (präfix-tolerant)
- inspectCallContext mit DEBUG-Instrumentation (nMsgs/msgFound/partTypes)

E2E vm-test: plugin-loaded 2.0.0, Config-Lesung (Managed+Global+Projekt,
V1-Map), System-Regel je Model-Request (Modell erklärt im Zitat-Block-
Format), evaluate allow+ask (bash→shell-Normalisierung), ask + ask.explained
mit echter Modell-Erklärung. Mock-Tests 17+5 grün.
2026-09-25 10:13:41 +02:00
m3ta-chiron 5be32a6fe6 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.
2026-09-25 10:01:58 +02:00
m3ta-chiron cbc1cb739f feat!: explain-permissions v2.0.0 — V2-only-Port auf die V2-Plugin-API (az-fleet-7x8)
Plugin.define({id: "az.explain-permissions", setup}) als Default-Export;
Hooks via ctx.permission.hook("evaluate"), ctx.session.hook("context"),
ctx.tool.hook("execute.before"); Event-Stream permission.v2.asked/replied;
Config-Lesung per fs mit V1+V2-Parser (JSONC-tolerant) statt hooks.config;
Action-Renames bash→shell/task→subagent, lsp/doom_loop verworfen.
KEIN V1-Export mehr (V2-only-Cut) — V1-Fleet bleibt gepinnt auf v1.3.0.

Verifikation: Mock-ctx-Tests 21/21 grün (Config-Parsing beider Formen,
System-Regel, evaluate/ask-Pfad, Hook↔Event-Dedup, replied, ENFORCE);
E2E vm-test folgt laut Issue.
2026-09-25 09:53:52 +02:00
m3ta-chiron bdeb27b775 feat: explain-permissions v1.3.0 — Erklärungen als Zitat-Block mit Zeilenstruktur
- SYSTEM_PROMPT_RULE: Erklärung als Markdown-Zitat-Block (>) mit fetten
  Labels, jeder Punkt (WAS passiert/WELCHE FOLGEN/WIE RISKANT) auf eigener
  Zeile — bessere Lesbarkeit + visuelle Hervorhebung in TUI und Desktop
  (beide rendern Blockquotes mit farbigem Balken; opencode 1.18.30 bietet
  keinen Anzeige-Transform-Hook, experimental.chat.messages.transform
  wirkt nur modellseitig — verifiziert)
- Desktop-Notifications: neuer plainText()-Filter streicht Markdown-Marker
  (>, **) aus dem Toast-Text
- EXPLAIN_FIRST_ERROR verweist auf das neue Block-Format
- READMEs: Zweck + Versionierung ergänzt, Root-Tabelle auf v1.3.0
2026-09-18 10:06:33 +02:00
m3ta-chiron 53b3eb651b feat: explain-permissions v1.2.0 — Cross-Platform-Notifications (az-fleet-wla)
- notify() mit Plattform-Zweigen: Linux notify-send (unverändert),
  macOS osascript, Windows PowerShell-Toast via WinRT + PowerShell-AUMID
  (keine App-Registrierung nötig); Titel/Text über env des Child-Prozesses
  (injektionssicher), XML-Escape via SecurityElement
- fail-soft Spawn in Node UND Bun (try/catch + error-Listener decken beide
  ENOENT-Semantiken ab) — fehlendes Binary/headless = stiller No-Op
- Log-Pfad-Entscheidung: Windows %LOCALAPPDATA%\opencode\ (idiomatisch,
  roamt nicht, Fallback ~\AppData\Local), sonst XDG-State unverändert;
  dokumentiert im README
- Live verifiziert: Linux permission.asked -> ask/ask.explained/replied im
  Audit-Log + Toast; Fail-Soft win32/darwin-Zweige in node+bun ohne Crash
2026-09-18 09:12:17 +02:00
4 changed files with 949 additions and 32 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ Das Repos ist **anonym lesbar** (kein Token für Pulls nötig), Schreiben läuft
| Plugin | Zweck | Version | | Plugin | Zweck | Version |
|---|---|---| |---|---|---|
| [`explain-permissions/`](explain-permissions/) | Erklärt jeden freigabepflichtigen Tool-Call laienverständlich (Was/Folgen/Risiko), Desktop-Notification + Audit-Log | v1.1.0 | | [`explain-permissions/`](explain-permissions/) | Erklärt jeden freigabepflichtigen Tool-Call laienverständlich (Was/Folgen/Risiko) als hervorgehobener Zitat-Block, Desktop-Notification + Audit-Log | v1.3.0 |
## Struktur & Konventionen ## Struktur & Konventionen
+126 -8
View File
@@ -2,9 +2,19 @@
Dieses Verzeichnis ist die **kanonische Quelle** für das opencode-Plugin Dieses Verzeichnis ist die **kanonische Quelle** für das opencode-Plugin
`explain-permissions`. Es lebt im Sammel-Repos `explain-permissions`. Es lebt im Sammel-Repos
[`opencode-plugins`](../README.md) und besteht aus genau einer Plugin-Datei [`opencode-plugins`](../README.md) und besteht aus dieser Doku plus je einer
(`explain-permissions.js`) plus dieser Doku. Wer das Plugin nutzt, bezieht es Plugin-Fassung pro Major-API — es gibt bewusst keine zweiten Solo-Kopien:
von hier — 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: Konsumenten:
@@ -20,7 +30,12 @@ Menschen ohne Computer-Kenntnisse. Das Plugin:
1. **Injiziert die Freigabe-Regel in den System-Prompt** (Was / Welche Folgen / 1. **Injiziert die Freigabe-Regel in den System-Prompt** (Was / Welche Folgen /
Wie riskant, auf Deutsch, vor jedem freigabepflichtigen Call) — inklusive Wie riskant, auf Deutsch, vor jedem freigabepflichtigen Call) — inklusive
der konkreten ask-/allow-Muster aus der opencode-Konfiguration. der konkreten ask-/allow-Muster aus der opencode-Konfiguration. Seit v1.3.0
mit festem Ausgabeformat: Zitat-Block (`>`) mit fetten Labels, jeder Punkt
auf eigener Zeile. TUI und OpenCode Desktop rendern das als abgesetzten
Kasten mit farbigem Balken — die Erklärung hebt sich damit klar vom restlichen
Chatverlauf ab, und die drei Punkte bleiben sauber untereinander lesbar.
Desktop-Notifications bekommen reinen Text (Markdown-Marker werden entfernt).
2. **Schickt eine Desktop-Notification**, wenn der Freigabe-Dialog erscheint — 2. **Schickt eine Desktop-Notification**, wenn der Freigabe-Dialog erscheint —
mit der deutschen Erklärung des Modells, Fallback: der Roh-Befehl. mit der deutschen Erklärung des Modells, Fallback: der Roh-Befehl.
3. **Schreibt ein JSONL-Audit-Log**: jeden Freigabe-Request (`ask`), die 3. **Schreibt ein JSONL-Audit-Log**: jeden Freigabe-Request (`ask`), die
@@ -30,19 +45,38 @@ Menschen ohne Computer-Kenntnisse. Das Plugin:
## Installation ## Installation
### Lokale Kiste (Checkout + Symlink) ### Lokale Kiste (Checkout + Symlink, V1)
```bash ```bash
git clone https://git.az-gruppe.com/AZ-Intec-GmbH/opencode-plugins.git ~/p/AZ/opencode-plugins 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 Danach opencode neu starten (siehe unten). Wichtig: nur EINE Datei dieses
Namens im Plugin-Ordner — sonst lädt opencode das Plugin doppelt. Namens im Plugin-Ordner — sonst lädt opencode das Plugin doppelt.
### opencode V2 (ab v2.0.0 — V2-only)
`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-v2" }]
}
```
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 ### 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. `git clone` über HTTPS reicht, kein Token nötig.
## Konfiguration via Environment-Variablen ## Konfiguration via Environment-Variablen
@@ -54,12 +88,40 @@ Pin auf ein Tag (z. B. `explain-permissions/v1.1.0`) oder `main`; anonymes
| `OPENCODE_EXPLAIN_INJECT=0` | System-Prompt-Regel abschalten | an | | `OPENCODE_EXPLAIN_INJECT=0` | System-Prompt-Regel abschalten | an |
| `OPENCODE_EXPLAIN_ENFORCE=1` | Hartes Erzwingen der Erklärung aktivieren (Call ohne vorherige Erklärung → Fehler) | aus | | `OPENCODE_EXPLAIN_ENFORCE=1` | Hartes Erzwingen der Erklärung aktivieren (Call ohne vorherige Erklärung → Fehler) | aus |
| `OPENCODE_EXPLAIN_DEBUG=1` | Permission-Events ins Log schreiben | aus | | `OPENCODE_EXPLAIN_DEBUG=1` | Permission-Events ins Log schreiben | aus |
| `OPENCODE_EXPLAIN_LOG_PATH=…` | Anderer Log-Pfad | `~/.local/state/opencode/explain-permissions.jsonl` | | `OPENCODE_EXPLAIN_LOG_PATH=…` | Anderer Log-Pfad | plattformabhängig (siehe unten) |
## Notifications je Plattform (v1.2.0)
| Plattform | Mechanismus | Anmerkung |
|---|---|---|
| Linux | `notify-send` (libnotify) | wie bisher |
| macOS | `osascript display notification` | Boardmittel, keine Abhängigkeit |
| Windows | PowerShell-Toast (WinRT) via `-EncodedCommand` | AppUserModelID = PowerShell-AUMID — **keine App-Registrierung nötig**; Titel/Text laufen über env-Variablen des Child-Prozesses (injektionssicher), XML-Escaping in PowerShell |
Alle Zweige sind **fail-soft**: fehlendes Binary, headless Session oder
Spawn-Fehler sind stille No-Ops — kein Crash, kein Log-Müll. Node und Bun
behandeln ENOENT beim Spawn unterschiedlich (asynchrones `error`-Event vs.
synchroner Wurf); das Plugin deckt beide Semantiken ab (try/catch plus
error-Listener) und läuft damit in beiden Runtime-Welten.
## Log-Pfad-Entscheidung (v1.2.0)
| Plattform | Default-Pfad | Begründung |
|---|---|---|
| Windows | `%LOCALAPPDATA%\opencode\explain-permissions.jsonl` | idiomatisch, roamt nicht, liegt neben anderen App-States; Fallback `~\AppData\Local\…`, falls `LOCALAPPDATA` nicht gesetzt |
| Linux/macOS | `~/.local/state/opencode/explain-permissions.jsonl` | XDG-State-Konvention (unverändert seit v1.1.0) |
`~/.local/state` würde zwar überall funktionieren (auch auf Windows), aber
`%LOCALAPPDATA%` ist auf Windows der etablierte Ort für pro-Nutzer-State —
Pilotnutzer-Support findet Dateien dort, wo Windows sie erwartet.
## Audit-Log ansehen ## Audit-Log ansehen
```bash ```bash
# Linux/macOS
tail -f ~/.local/state/opencode/explain-permissions.jsonl | jq . tail -f ~/.local/state/opencode/explain-permissions.jsonl | jq .
# Windows (PowerShell)
Get-Content "$env:LOCALAPPDATA\opencode\explain-permissions.jsonl" -Wait
``` ```
Relevante Events: Relevante Events:
@@ -74,9 +136,65 @@ Relevante Events:
**Plugin-Änderungen greifen erst nach Neustart von opencode** — eine neue **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.
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.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})` — Message-Teile im Feld `content` (2.0.16; `parts` wird tolerierend mitgelesen), Tool-Parts tragen die Call-ID als `id` (matcht `source.id` der Permission-Evaluation); im Flight befindliche Assistant-Messages sind bereits sichtbar |
| `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 ## 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.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). 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 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
(beide rendern Blockquotes mit farbigem Balken; verifiziert gegen opencode
1.18.30, das keinen Anzeige-Transform-Hook bietet — `experimental.chat.messages.transform`
wirkt nur modellseitig). Desktop-Notifications erhalten reinen Text ohne
Markdown-Marker; `EXPLAIN_FIRST_ERROR` verweist auf das neue Format.
- `explain-permissions/v1.2.0` — Cross-Platform-Notifications (Linux
notify-send / macOS osascript / Windows PowerShell-Toast mit PowerShell-AUMID,
keine App-Registrierung), fail-soft Spawn in Node- und Bun-Runtimes,
Windows-Log-Pfad `%LOCALAPPDATA%\opencode\` (Entscheidung siehe oben).
- `explain-permissions/v1.1.0` — Baseline, exakt der Stand der lokalen - `explain-permissions/v1.1.0` — Baseline, exakt der Stand der lokalen
Solo-Datei vom 17.09.2026 (Inhaltsgleichheit per SHA-256 verifiziert). Solo-Datei vom 17.09.2026 (Inhaltsgleichheit per SHA-256 verifiziert).
- Fortlaufende Versionsnummer in `PLUGIN_VERSION` im Dateikopf; Änderungen - Fortlaufende Versionsnummer in `PLUGIN_VERSION` im Dateikopf; Änderungen
@@ -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);
} }
+713
View File
@@ -0,0 +1,713 @@
// 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; die Message-Teile
* liegen im Feld `content` (verifiziert vm-test 2.0.16, az-fleet-7x8 — ältere
* SDK-Typen nennen es `parts`, beides wird gelesen) mit {type:"text", text}
* und {type:"tool", id, …}. Die Tool-Call-ID des Permission-Source (source.id)
* matcht die Tool-Part-ID (gleiches tooluse_-Format); trägt der Tool-Part
* keine ID, fällt die Suche auf den ersten Tool-Part der Message zurück.
* 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)) {
if (DEBUG) debugLog("inspect", { callID, result: "kein Nachrichten-Array" });
return { found: false };
}
const dbg = { callID, nMsgs: messages.length };
const isToolMatch = (part) =>
part?.type === "tool" && (part.id === callID || part.callID === callID);
const scan = (parts, allowFallback) => {
let toolIndex = parts.findIndex(isToolMatch);
// Fallback (nur im messageID-Zweig, wo die Message feststeht): trägt der
// Tool-Part keine ID, den ersten Tool-Part derselben Message nehmen.
if (toolIndex === -1 && allowFallback && parts.some((p) => p?.type === "tool")) {
toolIndex = parts.findIndex((p) => p?.type === "tool");
}
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 };
};
const messageParts = (msg) => {
if (Array.isArray(msg?.parts)) return msg.parts;
if (Array.isArray(msg?.content)) return msg.content;
return null;
};
// Bevorzugt die Message aus dem Permission-Source (messageID), Fallback:
// alle Messages scannen (dort ohne Approximation — nur exakte Treffer).
if (messageID) {
const msg = messages.find((m) => m?.id === messageID);
const parts = msg ? messageParts(msg) : null;
dbg.msgFound = !!msg;
dbg.partTypes = parts ? parts.map((p) => p.type).join(",") : null;
if (parts) {
const hit = scan(parts, true);
if (hit) {
if (DEBUG) debugLog("inspect", { ...dbg, via: "messageID+scan", explained: !!hit.explanation });
return hit;
}
// Persistenz-Lag (vm-test 2.0.16): Zur Evaluations-Zeit ist der
// Tool-Part im Message-Kontext ggf. noch nicht sichtbar. Die Message
// gehört aber genau zu diesem Call — alle ihre Text-Parts liegen per
// Definition VOR dem Call (der Call löst die Evaluation aus).
const texts = parts
.filter((p) => p?.type === "text" && typeof p.text === "string" && p.text.trim())
.map((p) => p.text.trim());
if (DEBUG) debugLog("inspect", { ...dbg, via: "messageID+lag", explained: texts.length > 0 });
return { found: true, explanation: texts.length ? texts.join("\n") : undefined };
}
}
for (const msg of messages) {
const parts = messageParts(msg);
if (!parts) continue;
const hit = scan(parts, false);
if (hit) {
if (DEBUG) debugLog("inspect", { ...dbg, via: "global-scan", explained: !!hit.explanation });
return hit;
}
}
if (DEBUG) debugLog("inspect", { ...dbg, via: "not-found" });
return { found: false };
} catch (err) {
if (DEBUG) debugLog("inspect", { callID, error: String(err) });
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, event.messageID);
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;
})();