feat: explain-permissions v1.1.0 — kanonische Quelle im Sammel-Repos (az-fleet-imn)
Erstes Plugin im neuen Sammel-Repos opencode-plugins: Baseline-Import der Solo-Datei von der lokalen Linux-Kiste (SHA-256-verifiziert identisch) plus README mit Zweck, Env-Variablen, Audit-Log-Anzeige und Neustart-Hinweis. Repo-Struktur: ein Ordner pro Plugin, pro-Plugin-Tags <name>/v<version>.
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
# opencode-plugins — kanonische Plugin-Sammlung für opencode
|
||||
|
||||
Dieses Repo ist die **eine Wahrheitsquelle** für opencode-Plugins der AZ-Gruppe.
|
||||
Es ist ein reines Content-Repo: hier leben nur die Plugin-Dateien plus ihre
|
||||
Doku — die Technik, die sie ausliefert, gehört in das Fleet-Repo `az-fleet`.
|
||||
|
||||
Das Repos ist **anonym lesbar** (kein Token für Pulls nötig), Schreiben läuft
|
||||
über die Organisation AZ-Intec-GmbH.
|
||||
|
||||
## Enthaltene Plugins
|
||||
|
||||
| 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 |
|
||||
|
||||
## Struktur & Konventionen
|
||||
|
||||
```
|
||||
opencode-plugins/
|
||||
└── <plugin-name>/
|
||||
├── <plugin-name>.js # die Plugin-Datei (genau eine pro Plugin)
|
||||
└── README.md # Doku: Zweck, Env-Variablen, Logs, Neustart-Hinweis
|
||||
```
|
||||
|
||||
- **Ein Ordner pro Plugin**, Name = Plugin-Name (kebab-case, keine Umlaute).
|
||||
- Version steht in `PLUGIN_VERSION` im Dateikopf des Plugins.
|
||||
- **Tags sind pro Plugin benamst**: `<plugin-name>/v<version>` — z. B.
|
||||
`explain-permissions/v1.1.0`. So kollidieren Versionen verschiedener Plugins
|
||||
nie. Ein Tag markiert immer exakt den Plugin-Stand bei Freigabe.
|
||||
- Neue Plugins: Ordner anlegen, in der Tabelle oben eintragen, Commit + Tag.
|
||||
|
||||
## Bezug
|
||||
|
||||
### Lokale Workstation (Checkout + Symlink)
|
||||
|
||||
```bash
|
||||
git clone https://git.az-gruppe.com/AZ-Intec-GmbH/opencode-plugins.git ~/p/AZ/opencode-plugins
|
||||
ln -sf ~/p/AZ/opencode-plugins/<plugin-name>/<plugin-name>.js ~/.config/opencode/plugins/<plugin-name>.js
|
||||
```
|
||||
|
||||
Danach opencode **neu starten**. Wichtig: nur EINE Datei dieses Namens im
|
||||
Plugin-Ordner — sonst lädt opencode das Plugin doppelt.
|
||||
|
||||
### Fleet (`az-fleet`)
|
||||
|
||||
Pin auf einen Tag (`<plugin-name>/v<version>`) oder `main`; anonymes
|
||||
`git clone` über HTTPS reicht.
|
||||
@@ -0,0 +1,83 @@
|
||||
# explain-permissions — opencode-Plugin für laienverständliche Tool-Call-Erklärungen
|
||||
|
||||
Dieses Verzeichnis ist die **kanonische Quelle** für das opencode-Plugin
|
||||
`explain-permissions`. Es lebt im Sammel-Repos
|
||||
[`opencode-plugins`](../README.md) und besteht aus genau einer Plugin-Datei
|
||||
(`explain-permissions.js`) plus dieser Doku. Wer das Plugin nutzt, bezieht es
|
||||
von hier — es gibt bewusst keine zweiten Solo-Kopien.
|
||||
|
||||
Konsumenten:
|
||||
|
||||
| Konsument | Bezug |
|
||||
|---|---|
|
||||
| Lokale Linux-Kiste (m3tam3re) | Repo-Checkout + Symlink in `~/.config/opencode/plugins/` |
|
||||
| Fleet (`az-fleet`) | später per Repo-Pin (anonym lesbar, kein Token für Pulls) |
|
||||
|
||||
## Zweck
|
||||
|
||||
Jeden berechtigungspflichtigen Tool-Call laienverständlich erklären — für
|
||||
Menschen ohne Computer-Kenntnisse. Das Plugin:
|
||||
|
||||
1. **Injiziert die Freigabe-Regel in den System-Prompt** (Was / Welche Folgen /
|
||||
Wie riskant, auf Deutsch, vor jedem freigabepflichtigen Call) — inklusive
|
||||
der konkreten ask-/allow-Muster aus der opencode-Konfiguration.
|
||||
2. **Schickt eine Desktop-Notification**, wenn der Freigabe-Dialog erscheint —
|
||||
mit der deutschen Erklärung des Modells, Fallback: der Roh-Befehl.
|
||||
3. **Schreibt ein JSONL-Audit-Log**: jeden Freigabe-Request (`ask`), die
|
||||
abgegebene Erklärung (`ask.explained`) und die Entscheidung (`replied`).
|
||||
4. **Erzwingt optional** (Default: aus), dass Erklärungen VOR dem Call kommen
|
||||
— sonst wirft der Call einen Fehler.
|
||||
|
||||
## Installation
|
||||
|
||||
### Lokale Kiste (Checkout + Symlink)
|
||||
|
||||
```bash
|
||||
git clone https://git.az-gruppe.com/AZ-Intec-GmbH/opencode-plugins.git ~/p/AZ/opencode-plugins
|
||||
ln -sf ~/p/AZ/opencode-plugins/explain-permissions/explain-permissions.js ~/.config/opencode/plugins/explain-permissions.js
|
||||
```
|
||||
|
||||
Danach opencode neu starten (siehe unten). Wichtig: nur EINE Datei dieses
|
||||
Namens im Plugin-Ordner — sonst lädt opencode das Plugin doppelt.
|
||||
|
||||
### Fleet
|
||||
|
||||
Pin auf ein Tag (z. B. `explain-permissions/v1.1.0`) oder `main`; anonymes
|
||||
`git clone` über HTTPS reicht, kein Token nötig.
|
||||
|
||||
## Konfiguration via Environment-Variablen
|
||||
|
||||
| Variable | Wirkung | Default |
|
||||
|---|---|---|
|
||||
| `OPENCODE_EXPLAIN_NOTIFY=0` | Desktop-Notifications abschalten | an |
|
||||
| `OPENCODE_EXPLAIN_LOG=0` | Audit-Log 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_DEBUG=1` | Permission-Events ins Log schreiben | aus |
|
||||
| `OPENCODE_EXPLAIN_LOG_PATH=…` | Anderer Log-Pfad | `~/.local/state/opencode/explain-permissions.jsonl` |
|
||||
|
||||
## Audit-Log ansehen
|
||||
|
||||
```bash
|
||||
tail -f ~/.local/state/opencode/explain-permissions.jsonl | jq .
|
||||
```
|
||||
|
||||
Relevante Events:
|
||||
|
||||
- `plugin-loaded` — mit `version`: welche Plugin-Version wann geladen wurde
|
||||
- `ask` — Freigabe-Request (Roh-Call, Muster, Session)
|
||||
- `ask.explained` — die Erklärung, die das Modell vor dem Call abgegeben hat
|
||||
- `replied` — die Entscheidung (`once` / `always` / `reject`)
|
||||
|
||||
## Wichtig: Neustart
|
||||
|
||||
**Plugin-Änderungen greifen erst nach Neustart von opencode** — eine neue
|
||||
Chat-Session im laufenden Prozess reicht nicht. Der Audit-Log-Eintrag
|
||||
`plugin-loaded` (mit Version) zeigt an, welche Version wann geladen wurde.
|
||||
|
||||
## Versionierung
|
||||
|
||||
- `explain-permissions/v1.1.0` — Baseline, exakt der Stand der lokalen
|
||||
Solo-Datei vom 17.09.2026 (Inhaltsgleichheit per SHA-256 verifiziert).
|
||||
- Fortlaufende Versionsnummer in `PLUGIN_VERSION` im Dateikopf; Änderungen
|
||||
bekommen ein Tag `explain-permissions/v<version>` in diesem Repo.
|
||||
@@ -0,0 +1,368 @@
|
||||
// explain-permissions.js — opencode plugin
|
||||
//
|
||||
// Zweck: Jeden berechtigungspflichtigen Tool-Call laienverständlich erklären —
|
||||
// WAS passiert, WELCHE FOLGEN, WIE RISKANT (Zielgruppe: nicht-technische Nutzer).
|
||||
//
|
||||
// Was es tut:
|
||||
// 1. config-Hook: liest die permission-Sektion der opencode-Config und injiziert
|
||||
// die konkreten ask-/allow-Muster verbindlich in den System-Prompt → das Modell
|
||||
// weiß genau, welche Calls eine Freigabe brauchen, statt zu raten.
|
||||
// (Achtung: Agent-Level-Overrides in agents/*.md sieht dieser Hook nicht —
|
||||
// im Zweifel erklärt das Modell dadurch eher zu viel als zu wenig.)
|
||||
// 2. permission.asked-Event (feuert immer, wenn der Permission-Dialog erscheint):
|
||||
// → Desktop-Notification mit der DEUTSCHEN ERKLÄRUNG des Modells (aus der
|
||||
// Message vor dem Call extrahiert) + Roh-Befehl als Detail; Fallback: Roh-Befehl
|
||||
// → Einträge in JSONL-Audit-Log: "ask" (Roh-Call) + "ask.explained" (Erklärung)
|
||||
// (permission.ask Hook ist registriert, feuert in v1.18.21 aber nicht;
|
||||
// permission.replied loggt die Entscheidung: once/always/reject)
|
||||
// 3. experimental.chat.system.transform:
|
||||
// → injiziert die Regel "Erst laienverständlich erklären (Was/Folgen/Risiko),
|
||||
// dann der Call" in den System-Prompt — in jeder Session, auch in Worktrees
|
||||
// ohne eigenes AGENTS.md
|
||||
// 4. Optional (env OPENCODE_EXPLAIN_ENFORCE=1): tool.execute.before wirft einen
|
||||
// Fehler, wenn das Modell einen berechtigungspflichtigen Call OHNE vorherige
|
||||
// Text-Erklärung im selben Message-Turn absetzt. Default: AUS (Dialog-Schleifen).
|
||||
//
|
||||
// Wichtig: Plugin-Änderungen greifen erst nach NEUSTART von opencode — eine neue
|
||||
// Chat-Session im laufenden Prozess reicht nicht. Der Audit-Log-Eintrag
|
||||
// "plugin-loaded" (mit Version) zeigt an, welche Version wann geladen wurde.
|
||||
//
|
||||
// Konfiguration via Environment-Variablen:
|
||||
// OPENCODE_EXPLAIN_NOTIFY=0 Notifications abschalten (Default: an)
|
||||
// OPENCODE_EXPLAIN_LOG=0 Audit-Log abschalten (Default: an)
|
||||
// OPENCODE_EXPLAIN_INJECT=0 System-Prompt-Regel abschalten (Default: an)
|
||||
// OPENCODE_EXPLAIN_ENFORCE=1 Hartes Erzwingen aktivieren (Default: aus)
|
||||
// OPENCODE_EXPLAIN_DEBUG=1 Permission-Events ins Log (Default: aus)
|
||||
// OPENCODE_EXPLAIN_LOG_PATH=… Anderer Log-Pfad
|
||||
// (Default: ~/.local/state/opencode/explain-permissions.jsonl)
|
||||
//
|
||||
// Log ansehen:
|
||||
// tail -f ~/.local/state/opencode/explain-permissions.jsonl | jq .
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const ENV = process.env;
|
||||
const PLUGIN_VERSION = "1.1.0";
|
||||
const NOTIFY = ENV.OPENCODE_EXPLAIN_NOTIFY !== "0";
|
||||
const LOG = ENV.OPENCODE_EXPLAIN_LOG !== "0";
|
||||
const INJECT = ENV.OPENCODE_EXPLAIN_INJECT !== "0";
|
||||
const ENFORCE = ENV.OPENCODE_EXPLAIN_ENFORCE === "1";
|
||||
const DEBUG = ENV.OPENCODE_EXPLAIN_DEBUG === "1";
|
||||
const LOG_PATH =
|
||||
ENV.OPENCODE_EXPLAIN_LOG_PATH ||
|
||||
path.join(os.homedir(), ".local", "state", "opencode", "explain-permissions.jsonl");
|
||||
|
||||
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:
|
||||
- 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."
|
||||
Beispiel (schlecht, zu technisch): „Ich führe rm -rf /tmp/foo aus."
|
||||
|
||||
Regeln:
|
||||
- Der genaue Befehl darf NACH der Erklärung 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 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 PERMISSION_KEYS = [
|
||||
"bash",
|
||||
"edit",
|
||||
"webfetch",
|
||||
"external_directory",
|
||||
"read",
|
||||
"glob",
|
||||
"grep",
|
||||
"task",
|
||||
"lsp",
|
||||
"skill",
|
||||
];
|
||||
|
||||
let permissionSummary;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interner Zustand
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const recordedPermissionIds = new Set();
|
||||
const askedCallIDs = new Map();
|
||||
|
||||
function capSet(set, max = 1000) {
|
||||
if (set.size > max) {
|
||||
for (const v of set) {
|
||||
set.delete(v);
|
||||
if (set.size <= max / 2) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Audit-Log + Notification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function appendLog(entry) {
|
||||
if (!LOG) return;
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
|
||||
fs.appendFileSync(LOG_PATH, JSON.stringify(entry) + "\n");
|
||||
} catch {
|
||||
// Log-Fehler dürfen den Agent nie blockieren
|
||||
}
|
||||
}
|
||||
|
||||
function notify(title, body) {
|
||||
if (!NOTIFY) return;
|
||||
try {
|
||||
const child = spawn(
|
||||
"notify-send",
|
||||
["-a", "opencode", "-i", "utilities-terminal", "-u", "normal", title, body.slice(0, 400)],
|
||||
{ detached: true, stdio: "ignore" },
|
||||
);
|
||||
child.unref();
|
||||
} catch {
|
||||
// keine Desktop-Session (headless) → egal
|
||||
}
|
||||
}
|
||||
|
||||
function debugLog(source, payload) {
|
||||
if (!DEBUG) return;
|
||||
let serialized;
|
||||
try {
|
||||
serialized = JSON.parse(JSON.stringify(payload ?? null));
|
||||
} catch {
|
||||
serialized = String(payload);
|
||||
}
|
||||
appendLog({ ts: new Date().toISOString(), event: "debug", source, payload: serialized });
|
||||
}
|
||||
|
||||
// Runtime-Payload von permission.asked (v1.18.21, per Debug-Lauf verifiziert):
|
||||
// { id, sessionID, permission, patterns[], metadata{command}, always[], tool{messageID, callID} }
|
||||
// permission.replied: { sessionID, requestID, reply }
|
||||
// permission.ask Hook ist registriert (Future-Proofing), feuert in 1.18.21 aber nicht.
|
||||
function permissionType(p) {
|
||||
return p.permission ?? p.type;
|
||||
}
|
||||
|
||||
function permissionCall(p) {
|
||||
return (
|
||||
p.metadata?.command ??
|
||||
(Array.isArray(p.patterns) ? p.patterns.join(" | ") : undefined) ??
|
||||
p.title ??
|
||||
"unbekannter Call"
|
||||
);
|
||||
}
|
||||
|
||||
// Aus der opencode-Config (config-Hook): pro Permission-Key die "ask"- und
|
||||
// "allow"-Muster. "*" bei ask = jeder Call fragt, allow-Ausnahmen entschärfen.
|
||||
function extractPermissionSummary(config) {
|
||||
const permission = config?.permission;
|
||||
if (!permission || typeof permission !== "object") return undefined;
|
||||
|
||||
const summary = {};
|
||||
for (const key of PERMISSION_KEYS) {
|
||||
const rule = permission[key];
|
||||
if (!rule) continue;
|
||||
|
||||
let asks = [];
|
||||
let allows = [];
|
||||
if (rule === "ask") {
|
||||
asks = ["**"];
|
||||
} else if (rule === "allow") {
|
||||
allows = ["**"];
|
||||
} else if (typeof rule === "object") {
|
||||
for (const [pattern, action] of Object.entries(rule)) {
|
||||
if (action === "ask") asks.push(pattern);
|
||||
else if (action === "allow") allows.push(pattern);
|
||||
}
|
||||
}
|
||||
if (asks.length || allows.length) summary[key] = { asks, allows };
|
||||
}
|
||||
return Object.keys(summary).length ? summary : undefined;
|
||||
}
|
||||
|
||||
function buildSystemRule() {
|
||||
if (!permissionSummary) return SYSTEM_PROMPT_RULE;
|
||||
|
||||
const lines = [];
|
||||
for (const [key, { asks, allows }] of Object.entries(permissionSummary)) {
|
||||
let line = `- ${key}: `;
|
||||
if (asks.length) line += `Freigabe nötig → erklären! Muster: ${asks.join(", ")}`;
|
||||
if (allows.length) {
|
||||
line += (asks.length ? " | automatisch erlaubt: " : "automatisch erlaubt: ");
|
||||
line += allows.join(", ");
|
||||
}
|
||||
lines.push(line);
|
||||
}
|
||||
return `${SYSTEM_PROMPT_RULE}
|
||||
|
||||
Diese Muster lösen laut opencode-Konfiguration eine Freigabe aus. Die Liste ist verbindlich: Erkläre JEDEN Call, der darauf passt — auch wenn er dir harmlos erscheint oder der User ihn explizit angefordert hat:
|
||||
${lines.join("\n")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a permission request. Idempotent per permission id
|
||||
* (permission.ask Hook und permission.asked/updated Events liefern dieselbe Anfrage).
|
||||
* Returns true only for the first recording of this permission id.
|
||||
*/
|
||||
function recordAsk(p) {
|
||||
if (!p || typeof p !== "object" || !p.id) return false;
|
||||
if (recordedPermissionIds.has(p.id)) return false;
|
||||
recordedPermissionIds.add(p.id);
|
||||
capSet(recordedPermissionIds);
|
||||
|
||||
const callID = p.tool?.callID ?? p.callID;
|
||||
if (callID) askedCallIDs.set(callID, p.id);
|
||||
|
||||
appendLog({
|
||||
ts: new Date().toISOString(),
|
||||
event: "ask",
|
||||
permissionID: p.id,
|
||||
sessionID: p.sessionID,
|
||||
messageID: p.tool?.messageID ?? p.messageID,
|
||||
callID,
|
||||
type: permissionType(p),
|
||||
call: permissionCall(p),
|
||||
patterns: Array.isArray(p.patterns) ? p.patterns : undefined,
|
||||
alwaysSuggestions: Array.isArray(p.always) ? p.always : undefined,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function recordReplied(props) {
|
||||
appendLog({
|
||||
ts: new Date().toISOString(),
|
||||
event: "replied",
|
||||
permissionID: props?.requestID ?? props?.permissionID,
|
||||
sessionID: props?.sessionID,
|
||||
reply: props?.reply ?? props?.response,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message-Kontext: Text-Parts VOR dem Tool-Part derselben Assistant-Message
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Inspect the message turn that contains the tool call.
|
||||
* Returns { found: true, explanation } with the joined text parts written
|
||||
* before the tool part, { found: true } without explanation if none exist,
|
||||
* or { found: false } when the call or API is unavailable — callers must
|
||||
* treat found:false as "never block, never enrich" (fail-open).
|
||||
*/
|
||||
async function inspectCallContext(client, sessionID, callID) {
|
||||
try {
|
||||
const result = await client.session.messages({ path: { id: sessionID } });
|
||||
const messages = result?.data ?? result;
|
||||
if (!Array.isArray(messages)) return { found: false };
|
||||
|
||||
for (const msg of messages) {
|
||||
const parts = msg?.parts;
|
||||
if (!Array.isArray(parts)) continue;
|
||||
const toolIndex = parts.findIndex(
|
||||
(part) => part?.type === "tool" && part?.callID === callID,
|
||||
);
|
||||
if (toolIndex === -1) continue;
|
||||
|
||||
const texts = [];
|
||||
for (let i = 0; i < toolIndex; i++) {
|
||||
const part = parts[i];
|
||||
if (part?.type === "text" && typeof part.text === "string" && part.text.trim()) {
|
||||
texts.push(part.text.trim());
|
||||
}
|
||||
}
|
||||
return { found: true, explanation: texts.length ? texts.join("\n") : undefined };
|
||||
}
|
||||
return { found: false };
|
||||
} catch {
|
||||
return { found: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function explainAndNotify(client, p) {
|
||||
const callID = p.tool?.callID ?? p.callID;
|
||||
const rawCall = permissionCall(p);
|
||||
const type = permissionType(p) ?? "Tool";
|
||||
|
||||
let explanation;
|
||||
if (callID) {
|
||||
const ctx = await inspectCallContext(client, p.sessionID, callID);
|
||||
explanation = ctx.explanation;
|
||||
}
|
||||
|
||||
if (explanation) {
|
||||
appendLog({
|
||||
ts: new Date().toISOString(),
|
||||
event: "ask.explained",
|
||||
permissionID: p.id,
|
||||
explanation: explanation.slice(0, 2000),
|
||||
});
|
||||
}
|
||||
|
||||
const body = explanation
|
||||
? `${explanation}\n—\n${type}: ${rawCall}\n→ im opencode-UI antworten`
|
||||
: `${rawCall}\n→ im opencode-UI antworten`;
|
||||
notify(`opencode · Freigabe nötig (${type})`, body);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ExplainPermissionsPlugin = async ({ client }) => {
|
||||
const hooks = {};
|
||||
|
||||
appendLog({ ts: new Date().toISOString(), event: "plugin-loaded", version: PLUGIN_VERSION });
|
||||
|
||||
hooks.config = async (config) => {
|
||||
permissionSummary = extractPermissionSummary(config);
|
||||
if (DEBUG) debugLog("permission-summary", permissionSummary ?? "keine");
|
||||
};
|
||||
|
||||
// Beobachter-Status: liest input, ändert output.status nie.
|
||||
hooks["permission.ask"] = async (input) => {
|
||||
debugLog("permission.ask-hook", input);
|
||||
if (recordAsk(input)) void explainAndNotify(client, input);
|
||||
};
|
||||
|
||||
hooks.event = async ({ event }) => {
|
||||
const type = event?.type;
|
||||
const props = event?.properties ?? {};
|
||||
if (DEBUG && typeof type === "string" && type.startsWith("permission")) {
|
||||
debugLog(`event:${type}`, props);
|
||||
}
|
||||
if (type === "permission.asked" || type === "permission.updated") {
|
||||
if (recordAsk(props)) void explainAndNotify(client, props);
|
||||
} else if (type === "permission.replied") {
|
||||
recordReplied(props);
|
||||
}
|
||||
};
|
||||
|
||||
if (INJECT) {
|
||||
hooks["experimental.chat.system.transform"] = async (_input, output) => {
|
||||
const rule = buildSystemRule();
|
||||
if (DEBUG) debugLog("system-rule", rule);
|
||||
output.system.push(rule);
|
||||
};
|
||||
}
|
||||
|
||||
if (ENFORCE) {
|
||||
hooks["tool.execute.before"] = async (input) => {
|
||||
if (!askedCallIDs.has(input.callID)) return;
|
||||
const ctx = await inspectCallContext(client, input.sessionID, input.callID);
|
||||
if (!ctx.found || ctx.explanation) return;
|
||||
askedCallIDs.delete(input.callID);
|
||||
throw new Error(EXPLAIN_FIRST_ERROR);
|
||||
};
|
||||
}
|
||||
|
||||
return hooks;
|
||||
};
|
||||
Reference in New Issue
Block a user