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.
This commit is contained in:
@@ -45,9 +45,27 @@ ln -sf ~/p/AZ/opencode-plugins/explain-permissions/explain-permissions.js ~/.con
|
|||||||
Danach opencode neu starten (siehe unten). Wichtig: nur EINE Datei dieses
|
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)
|
||||||
|
|
||||||
|
Die Datei exportiert `Plugin.define({id: "az.explain-permissions", setup})`
|
||||||
|
als Default-Export und wird in `opencode.json(c)` unter dem V2-Schlüssel
|
||||||
|
`plugins` referenziert (Einzel-Datei-Eintrag, absolute `file:///`-URL):
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"plugins": [{ "package": "file:///C:/ProgramData/opencode/plugins/explain-permissions.js" }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternativ läuft die Datei auch unverändert als Datei-Plugin in
|
||||||
|
`~/.config/opencode/plugins/` bzw. `.opencode/plugins/` (dort wird sie
|
||||||
|
automatisch entdeckt, kein Config-Eintrag nötig). Benötigt opencode V2
|
||||||
|
(Core 2.0.x); V1 (1.18.x) lädt sie bewusst NICHT (V2-only-Cut) — die V1-Fleet
|
||||||
|
bleibt auf der gepinnten v1.3.0.
|
||||||
|
|
||||||
### Fleet
|
### 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
|
||||||
@@ -107,9 +125,48 @@ 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.v2.replied` im öffentlichen Event-Stream (`ctx.event.subscribe`), Payload `{sessionID, requestID, reply}` |
|
||||||
|
| `experimental.chat.system.transform` | `ctx.session.hook("context")` → `event.system.push({type: "text", text})` — Agent-Loop inkl. Tool-Continuations |
|
||||||
|
| `tool.execute.before` (ENFORCE) | `ctx.tool.hook("execute.before")` — Event `{tool, sessionID, callID, input}` |
|
||||||
|
| `client.session.messages({path:{id}})` | `ctx.session.context({sessionID})` — Tool-Parts tragen die Call-ID als `id` (matcht `source.id` der Permission-Evaluation) |
|
||||||
|
| `plugin-loaded`-Audit-Eintrag beim Laden | in `setup()` (jetzt mit Plugin-ID, opencode-Version, Location) |
|
||||||
|
|
||||||
|
Unverändert übernommen: Notification-Logik je Plattform (PowerShell-WinRT-AUMID,
|
||||||
|
injektionssicher), JSONL-Audit-Log samt Env-Flags, fail-soft-Muster, das
|
||||||
|
Erklär-Format (Zitat-Block) und die ENFORCE-Semantik (greift nur auf Calls,
|
||||||
|
die durch einen Freigabe-Dialog gegangen sind).
|
||||||
|
|
||||||
|
Der `@opencode/plugin`-Import ist bewusst dynamisch mit Fallback auf die
|
||||||
|
Rohestform `{id, setup}`: Schlägt die Paketauflösung fehl (Einzel-Datei-Package
|
||||||
|
außerhalb eines npm-Kontexts), bleibt der Fleet-Load fail-soft und das Plugin
|
||||||
|
trotzdem aktiv, statt beim Laden der ganzen Datei zu werfen.
|
||||||
|
|
||||||
## Versionierung
|
## Versionierung
|
||||||
|
|
||||||
|
- `explain-permissions/v2.0.0` — V2-only-Port auf die V2-Plugin-API (Core
|
||||||
|
2.0.x, az-fleet-7x8): `Plugin.define`-Default-Export, Hooks via
|
||||||
|
`ctx.permission.hook("evaluate")` / `ctx.session.hook("context")` /
|
||||||
|
`ctx.tool.hook("execute.before")`, Event-Stream `permission.v2.asked` /
|
||||||
|
`permission.v2.replied`, Config-Lesung per `fs` mit V1+V2-Parser
|
||||||
|
(JSONC-tolerant) statt `hooks.config`, Action-Namen auf V2
|
||||||
|
(bash→shell, task→subagent; lsp/doom_loop verworfen). KEIN V1-Export mehr
|
||||||
|
(V2-only-Cut, Entscheidung 25.09.) — V1-Fleet bleibt gepinnt auf v1.3.0.
|
||||||
|
Verifikation: Mock-ctx-Tests 21/21 grün (Config-Parsing beider Formen,
|
||||||
|
System-Regel-Injektion, evaluate/ask-Pfad, Hook↔Event-Dedup, replied-Audit,
|
||||||
|
ENFORCE); E2E auf vm-test laut Issue az-fleet-7x8.
|
||||||
- `explain-permissions/v1.3.0` — Erklärungs-Format überarbeitet: Zitat-Block
|
- `explain-permissions/v1.3.0` — Erklärungs-Format überarbeitet: Zitat-Block
|
||||||
(`>`) mit fetten Labels (`**WAS passiert:**` usw.), jeder Punkt auf eigener
|
(`>`) mit fetten Labels (`**WAS passiert:**` usw.), jeder Punkt auf eigener
|
||||||
Zeile — bessere Lesbarkeit und visuelle Hervorhebung in TUI und Desktop
|
Zeile — bessere Lesbarkeit und visuelle Hervorhebung in TUI und Desktop
|
||||||
|
|||||||
@@ -1,36 +1,52 @@
|
|||||||
// explain-permissions.js — opencode plugin
|
// explain-permissions.js — opencode V2-Plugin (Plugin-API v2, Core 2.0.x)
|
||||||
//
|
//
|
||||||
// Kanonische Quelle: git.az-gruppe.com/AZ-Intec-GmbH/opencode-plugins
|
// Kanonische Quelle: git.az-gruppe.com/AZ-Intec-GmbH/opencode-plugins
|
||||||
// (Tag explain-permissions/v1.3.0). Fleet-Bezug: az-fleet vendort diese
|
// (Tag explain-permissions/v2.0.0). Fleet-Bezug: az-fleet vendort diese
|
||||||
// Datei nach config/managed/plugins/ (Pin mit sha256 dort in
|
// Datei nach config/managed/plugins/ (Pin mit sha256, ADR-0010-Logik —
|
||||||
// roles/managed_config — Byte-Gleichheit ist Vertragsgrundlage).
|
// Byte-Gleichheit ist Vertragsgrundlage).
|
||||||
//
|
//
|
||||||
// Zweck: Jeden berechtigungspflichtigen Tool-Call laienverständlich erklären —
|
// V2-only-Cut (v2.0.0, Entscheidung 25.09.): KEIN V1-/Dual-Support-Export
|
||||||
// WAS passiert, WELCHE FOLGEN, WIE RISKANT (Zielgruppe: nicht-technische Nutzer).
|
// mehr. Die V1-Fleet (1.18.x, gepinnt) lädt bewusst weiter explain-permissions
|
||||||
|
// v1.3.0; diese Datei wird erst mit dem atomaren V2-Rollout gepinnt und
|
||||||
|
// referenziert (ADR-0014: V2 lädt den Managed-Layer bis zur Behebung des
|
||||||
|
// Upstream-Bugs nicht — kein V2-Rollout vorher).
|
||||||
//
|
//
|
||||||
// Was es tut:
|
// Zweck (unverändert): Jeden berechtigungspflichtigen Tool-Call laienverständlich
|
||||||
// 1. config-Hook: liest die permission-Sektion der opencode-Config und injiziert
|
// erklären — WAS passiert, WELCHE FOLGEN, WIE RISKANT (Zielgruppe:
|
||||||
// die konkreten ask-/allow-Muster verbindlich in den System-Prompt → das Modell
|
// nicht-technische Nutzer).
|
||||||
// weiß genau, welche Calls eine Freigabe brauchen, statt zu raten.
|
//
|
||||||
// (Achtung: Agent-Level-Overrides in agents/*.md sieht dieser Hook nicht —
|
// Was es tut (V2-Mechanik, Migrationsmappe az-fleet-7x8):
|
||||||
// im Zweifel erklärt das Modell dadurch eher zu viel als zu wenig.)
|
// 1. Config-Lese-Ersatz für den entfallenen config-Hook: Die Config-Dateien
|
||||||
// 2. permission.asked-Event (feuert immer, wenn der Permission-Dialog erscheint):
|
// (Managed-Verzeichnis, Global, Projekt, OPENCODE_CONFIG) werden direkt
|
||||||
// → Desktop-Notification mit der DEUTSCHEN ERKLÄRUNG des Modells (aus der
|
// per fs gelesen — BEIDE Regel-Formate: V1 permission-Map (String- und
|
||||||
// Message vor dem Call extrahiert) + Roh-Befehl als Detail; Fallback: Roh-Befehl
|
// Objekt-Form) und V2 permissions-Array {action, resource, effect}. Die
|
||||||
// → Einträge in JSONL-Audit-Log: "ask" (Roh-Call) + "ask.explained" (Erklärung)
|
// konkreten ask-/allow-/deny-Muster wandern verbindlich in den
|
||||||
// (permission.ask Hook ist registriert, feuert in v1.18.21 aber nicht;
|
// System-Prompt → das Modell weiß, welche Calls eine Freigabe brauchen,
|
||||||
// permission.replied loggt die Entscheidung: once/always/reject)
|
// statt zu raten. (Agent-Level-Overrides in agents.<id>.permissions sieht
|
||||||
// 3. experimental.chat.system.transform:
|
// dieser Summary nicht — im Zweifel erklärt das Modell dadurch eher zu
|
||||||
// → injiziert die Regel "Erst laienverständlich erklären (Was/Folgen/Risiko),
|
// viel als zu wenig.)
|
||||||
// dann der Call" in den System-Prompt — in jeder Session, auch in Worktrees
|
// 2. ctx.permission.hook("evaluate"): feuert für allow UND ask nach der
|
||||||
// ohne eigenes AGENTS.md. Seit v1.3.0 mit festem Ausgabeformat: Zitat-Block
|
// 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
|
// (>) mit fetten Labels, jeder Punkt auf eigener Zeile — TUI und Desktop
|
||||||
// rendern das als klar abgesetzten Kasten mit farbigem Balken.
|
// rendern das als klar abgesetzten Kasten mit farbigem Balken.
|
||||||
// 4. Optional (env OPENCODE_EXPLAIN_ENFORCE=1): tool.execute.before wirft einen
|
// 5. Optional (env OPENCODE_EXPLAIN_ENFORCE=1): ctx.tool.hook("execute.before")
|
||||||
// Fehler, wenn das Modell einen berechtigungspflichtigen Call OHNE vorherige
|
// wirft einen Fehler, wenn das Modell einen freigabepflichtigen Call OHNE
|
||||||
// Text-Erklärung im selben Message-Turn absetzt. Default: AUS (Dialog-Schleifen).
|
// 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 (v1.2.0):
|
// Notifications je Plattform (unverändert seit v1.2.0):
|
||||||
// Linux notify-send (libnotify)
|
// Linux notify-send (libnotify)
|
||||||
// macOS osascript "display notification" (Boardmittel)
|
// macOS osascript "display notification" (Boardmittel)
|
||||||
// Windows PowerShell-Toast via WinRT; AppUserModelID = PowerShell-AUMID
|
// Windows PowerShell-Toast via WinRT; AppUserModelID = PowerShell-AUMID
|
||||||
@@ -40,11 +56,14 @@
|
|||||||
// sind stille No-Ops (Node und Bun behandeln ENOENT verschieden — try/catch
|
// sind stille No-Ops (Node und Bun behandeln ENOENT verschieden — try/catch
|
||||||
// plus error-Listener decken beide Semantiken ab).
|
// plus error-Listener decken beide Semantiken ab).
|
||||||
//
|
//
|
||||||
// Wichtig: Plugin-Änderungen greifen erst nach NEUSTART von opencode — eine neue
|
// V2-Neustart-Semantik: Änderungen an BEOBACHTETEN Config-Verzeichnissen
|
||||||
// Chat-Session im laufenden Prozess reicht nicht. Der Audit-Log-Eintrag
|
// laden Plugins automatisch neu; die Plugin-Datei selbst (ProgramData/verwaltete
|
||||||
// "plugin-loaded" (mit Version) zeigt an, welche Version wann geladen wurde.
|
// Ablage) sowie Config-Wirksamkeit generell erfordern `opencode service
|
||||||
|
// restart` (der Background-Service besitzt und cachet die Config — ADR-0014).
|
||||||
|
// Der Audit-Log-Eintrag "plugin-loaded" (mit Version) zeigt an, welche Version
|
||||||
|
// wann geladen wurde.
|
||||||
//
|
//
|
||||||
// Konfiguration via Environment-Variablen:
|
// Konfiguration via Environment-Variablen (unverändert):
|
||||||
// OPENCODE_EXPLAIN_NOTIFY=0 Notifications abschalten (Default: an)
|
// OPENCODE_EXPLAIN_NOTIFY=0 Notifications abschalten (Default: an)
|
||||||
// OPENCODE_EXPLAIN_LOG=0 Audit-Log abschalten (Default: an)
|
// OPENCODE_EXPLAIN_LOG=0 Audit-Log abschalten (Default: an)
|
||||||
// OPENCODE_EXPLAIN_INJECT=0 System-Prompt-Regel abschalten (Default: an)
|
// OPENCODE_EXPLAIN_INJECT=0 System-Prompt-Regel abschalten (Default: an)
|
||||||
@@ -65,7 +84,8 @@ import os from "node:os";
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
const ENV = process.env;
|
const ENV = process.env;
|
||||||
const PLUGIN_VERSION = "1.3.0";
|
const PLUGIN_VERSION = "2.0.0";
|
||||||
|
const PLUGIN_ID = "az.explain-permissions";
|
||||||
const NOTIFY = ENV.OPENCODE_EXPLAIN_NOTIFY !== "0";
|
const 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";
|
||||||
@@ -111,18 +131,13 @@ Regeln:
|
|||||||
|
|
||||||
const EXPLAIN_FIRST_ERROR = `EXPLAIN_FIRST: Du hast den Tool-Call ohne vorherige Erklärung abgesetzt. Schreibe zuerst eine Erklärung auf Deutsch, laienverständlich (kein Fachjargon) — als Zitat-Block („> “), jeder Punkt auf eigener Zeile: **WAS passiert:** / **WELCHE FOLGEN:** / **WIE RISKANT:**. Führe den Call danach erneut aus.`;
|
const 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 = [
|
// V2-Action-Namen der Built-in-Tools (V2-Permissions-Doku): bash→shell,
|
||||||
"bash",
|
// task→subagent; write/patch laufen unter edit. lsp/doom_loop sind KEINE V2-
|
||||||
"edit",
|
// Core-Actions mehr und werden beim Parsen verworfen. MCP-Tools tragen
|
||||||
"webfetch",
|
// <server>_<tool> als Action — unbekannte Namen lassen wir durch (die
|
||||||
"external_directory",
|
// Summary listet nur, was konkret konfiguriert ist).
|
||||||
"read",
|
const V1_ACTION_ALIASES = { bash: "shell", task: "subagent", write: "edit", patch: "edit" };
|
||||||
"glob",
|
const DEAD_V1_ACTIONS = ["lsp", "doom_loop"];
|
||||||
"grep",
|
|
||||||
"task",
|
|
||||||
"lsp",
|
|
||||||
"skill",
|
|
||||||
];
|
|
||||||
|
|
||||||
let permissionSummary;
|
let permissionSummary;
|
||||||
|
|
||||||
@@ -130,8 +145,12 @@ let permissionSummary;
|
|||||||
// Interner Zustand
|
// Interner Zustand
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const recordedPermissionIds = new Set();
|
// Dedup-Schlüssel: bevorzugt die Tool-Call-ID (der evaluate-Hook läuft VOR der
|
||||||
const askedCallIDs = new Map();
|
// 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) {
|
function capSet(set, max = 1000) {
|
||||||
if (set.size > max) {
|
if (set.size > max) {
|
||||||
@@ -229,47 +248,151 @@ function debugLog(source, payload) {
|
|||||||
appendLog({ ts: new Date().toISOString(), event: "debug", source, payload: serialized });
|
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} }
|
// Config-Lesung (Ersatz für den entfallenen V1-config-Hook)
|
||||||
// permission.replied: { sessionID, requestID, reply }
|
// ---------------------------------------------------------------------------
|
||||||
// permission.ask Hook ist registriert (Future-Proofing), feuert in 1.18.21 aber nicht.
|
|
||||||
function permissionType(p) {
|
// JSONC-tolerantes Strippen: Zeilen-/Blockkommentare und abschließende Kommas
|
||||||
return p.permission ?? p.type;
|
// NUR außerhalb von String-Literalen entfernen (Zustandsautomat über das
|
||||||
|
// Zeichenfenster — kein Regex, der Strings zerstören könnte). Best effort:
|
||||||
|
// Parse-Fehler fallen im Caller still durch (fail-soft, Summary bleibt leer).
|
||||||
|
function stripJsonc(text) {
|
||||||
|
let out = "";
|
||||||
|
let i = 0;
|
||||||
|
let inString = false;
|
||||||
|
while (i < text.length) {
|
||||||
|
const c = text[i];
|
||||||
|
const next = text[i + 1];
|
||||||
|
if (inString) {
|
||||||
|
out += c;
|
||||||
|
if (c === "\\") {
|
||||||
|
if (i + 1 < text.length) out += next;
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === '"') inString = false;
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === '"') {
|
||||||
|
inString = true;
|
||||||
|
out += c;
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === "/" && next === "/") {
|
||||||
|
while (i < text.length && text[i] !== "\n") i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === "/" && next === "*") {
|
||||||
|
i += 2;
|
||||||
|
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i += 1;
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out += c;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
// Abschließende Kommas vor } oder ] entfernen (außerhalb von Strings — nach
|
||||||
|
// dem Kommentar-Strip sicher, weil nur noch ",}" / ",]"-Muster mit optionalen
|
||||||
|
// Whitespaces übriggs bleiben können).
|
||||||
|
return out.replace(/,(\s*[}\]])/g, "$1");
|
||||||
}
|
}
|
||||||
|
|
||||||
function permissionCall(p) {
|
function parseConfigFile(file) {
|
||||||
return (
|
try {
|
||||||
p.metadata?.command ??
|
return JSON.parse(stripJsonc(fs.readFileSync(file, "utf8")));
|
||||||
(Array.isArray(p.patterns) ? p.patterns.join(" | ") : undefined) ??
|
} catch {
|
||||||
p.title ??
|
return undefined;
|
||||||
"unbekannter Call"
|
}
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aus der opencode-Config (config-Hook): pro Permission-Key die "ask"- und
|
// Systemweites Managed-Verzeichnis (V1-Fleet-Anker; V2 lädt es Stand 2.0.16
|
||||||
// "allow"-Muster. "*" bei ask = jeder Call fragt, allow-Ausnahmen entschärfen.
|
// nicht — siehe ADR-0014 —, aber lesen schadet nicht und greift, sobald der
|
||||||
function extractPermissionSummary(config) {
|
// 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;
|
const permission = config?.permission;
|
||||||
if (!permission || typeof permission !== "object") return undefined;
|
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 = {};
|
const summary = {};
|
||||||
for (const key of PERMISSION_KEYS) {
|
for (const file of configCandidates(projectDir)) {
|
||||||
const rule = permission[key];
|
const config = parseConfigFile(file);
|
||||||
if (!rule) continue;
|
if (!config) continue;
|
||||||
|
if (DEBUG) debugLog(`config:${file}`, { permission: config.permission ?? null, permissions: config.permissions ?? null });
|
||||||
let asks = [];
|
extractV1Permission(config, summary);
|
||||||
let allows = [];
|
extractV2Permissions(config, summary);
|
||||||
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;
|
return Object.keys(summary).length ? summary : undefined;
|
||||||
}
|
}
|
||||||
@@ -278,13 +401,17 @@ function buildSystemRule() {
|
|||||||
if (!permissionSummary) return SYSTEM_PROMPT_RULE;
|
if (!permissionSummary) return SYSTEM_PROMPT_RULE;
|
||||||
|
|
||||||
const lines = [];
|
const lines = [];
|
||||||
for (const [key, { asks, allows }] of Object.entries(permissionSummary)) {
|
for (const [action, { asks, allows, denys }] of Object.entries(permissionSummary)) {
|
||||||
let line = `- ${key}: `;
|
let line = `- ${action}: `;
|
||||||
if (asks.length) line += `Freigabe nötig → erklären! Muster: ${asks.join(", ")}`;
|
if (asks.length) line += `Freigabe nötig → erklären! Muster: ${asks.join(", ")}`;
|
||||||
if (allows.length) {
|
if (allows.length) {
|
||||||
line += (asks.length ? " | automatisch erlaubt: " : "automatisch erlaubt: ");
|
line += (asks.length ? " | automatisch erlaubt: " : "automatisch erlaubt: ");
|
||||||
line += allows.join(", ");
|
line += allows.join(", ");
|
||||||
}
|
}
|
||||||
|
if (denys.length) {
|
||||||
|
line += (asks.length || allows.length ? " | blockiert: " : "blockiert: ");
|
||||||
|
line += denys.join(", ");
|
||||||
|
}
|
||||||
lines.push(line);
|
lines.push(line);
|
||||||
}
|
}
|
||||||
return `${SYSTEM_PROMPT_RULE}
|
return `${SYSTEM_PROMPT_RULE}
|
||||||
@@ -293,31 +420,56 @@ Diese Muster lösen laut opencode-Konfiguration eine Freigabe aus. Die Liste ist
|
|||||||
${lines.join("\n")}`;
|
${lines.join("\n")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Ask-/Reply-Protokollierung
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Vereinheitlichte Anfrage aus den beiden Quellen (evaluate-Hook,
|
||||||
|
// permission.v2.asked-Event). Felder sind optional; callID ist die Tool-Call-ID
|
||||||
|
// aus source (V2: source.id), requestID die Permission-Request-ID.
|
||||||
|
function permissionCall(p) {
|
||||||
|
return (
|
||||||
|
p.metadata?.command ??
|
||||||
|
(Array.isArray(p.resources) ? p.resources.join(" | ") : undefined) ??
|
||||||
|
p.title ??
|
||||||
|
"unbekannter Call"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function askKey(p) {
|
||||||
|
return (
|
||||||
|
p.callID ??
|
||||||
|
p.requestID ??
|
||||||
|
(p.sessionID ? `${p.sessionID}:${p.action}:${Array.isArray(p.resources) ? p.resources.join("|") : ""}` : undefined)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record a permission request. Idempotent per permission id
|
* Freigabe-Anfrage protokollieren. Idempotent pro Tool-Call (evaluate-Hook und
|
||||||
* (permission.ask Hook und permission.asked/updated Events liefern dieselbe Anfrage).
|
* permission.v2.asked liefern dieselbe Anfrage). Liefert true nur bei der
|
||||||
* Returns true only for the first recording of this permission id.
|
* ersten Aufzeichnung dieses Calls.
|
||||||
*/
|
*/
|
||||||
function recordAsk(p) {
|
function recordAsk(p) {
|
||||||
if (!p || typeof p !== "object" || !p.id) return false;
|
if (!p || typeof p !== "object") return false;
|
||||||
if (recordedPermissionIds.has(p.id)) return false;
|
const key = askKey(p);
|
||||||
recordedPermissionIds.add(p.id);
|
if (!key) return false;
|
||||||
capSet(recordedPermissionIds);
|
if (recordedAskKeys.has(key)) return false;
|
||||||
|
recordedAskKeys.add(key);
|
||||||
|
capSet(recordedAskKeys);
|
||||||
|
|
||||||
const callID = p.tool?.callID ?? p.callID;
|
if (p.callID) askedCallIDs.set(p.callID, p.requestID);
|
||||||
if (callID) askedCallIDs.set(callID, p.id);
|
|
||||||
|
|
||||||
appendLog({
|
appendLog({
|
||||||
ts: new Date().toISOString(),
|
ts: new Date().toISOString(),
|
||||||
event: "ask",
|
event: "ask",
|
||||||
permissionID: p.id,
|
permissionID: p.requestID,
|
||||||
sessionID: p.sessionID,
|
sessionID: p.sessionID,
|
||||||
messageID: p.tool?.messageID ?? p.messageID,
|
messageID: p.messageID,
|
||||||
callID,
|
callID: p.callID,
|
||||||
type: permissionType(p),
|
action: p.action,
|
||||||
call: permissionCall(p),
|
call: permissionCall(p),
|
||||||
patterns: Array.isArray(p.patterns) ? p.patterns : undefined,
|
resources: Array.isArray(p.resources) ? p.resources : undefined,
|
||||||
alwaysSuggestions: Array.isArray(p.always) ? p.always : undefined,
|
alwaysSuggestions: Array.isArray(p.save) ? p.save : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -334,30 +486,30 @@ function recordReplied(props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Message-Kontext: Text-Parts VOR dem Tool-Part derselben Assistant-Message
|
// Message-Kontext: Text-Parts VOR dem Tool-Part derselben Message
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inspect the message turn that contains the tool call.
|
* Den Message-Turn untersuchen, der den Tool-Call enthält.
|
||||||
* Returns { found: true, explanation } with the joined text parts written
|
* V2: ctx.session.context({sessionID}) liefert die Messages; Assistant-Parts
|
||||||
* before the tool part, { found: true } without explanation if none exist,
|
* sind u. a. {type:"text", text} und {type:"tool", id, name, state} — die
|
||||||
* or { found: false } when the call or API is unavailable — callers must
|
* Tool-Call-ID des Permission-Source (source.id) matcht die Part-ID.
|
||||||
* treat found:false as "never block, never enrich" (fail-open).
|
* Liefert { found: true, explanation } mit den Text-Parts vor dem Tool-Part,
|
||||||
|
* { found: true } ohne Erklärung, oder { found: false }, wenn Call oder API
|
||||||
|
* nicht verfügbar sind — Caller behandeln found:false als „niemals blocken,
|
||||||
|
* nie anreichern" (fail-open).
|
||||||
*/
|
*/
|
||||||
async function inspectCallContext(client, sessionID, callID) {
|
async function inspectCallContext(ctx, sessionID, callID, messageID) {
|
||||||
try {
|
try {
|
||||||
const result = await client.session.messages({ path: { id: sessionID } });
|
const messages = await ctx.session.context({ sessionID });
|
||||||
const messages = result?.data ?? result;
|
|
||||||
if (!Array.isArray(messages)) return { found: false };
|
if (!Array.isArray(messages)) return { found: false };
|
||||||
|
|
||||||
for (const msg of messages) {
|
const isToolMatch = (part) =>
|
||||||
const parts = msg?.parts;
|
part?.type === "tool" && (part.id === callID || part.callID === callID);
|
||||||
if (!Array.isArray(parts)) continue;
|
|
||||||
const toolIndex = parts.findIndex(
|
|
||||||
(part) => part?.type === "tool" && part?.callID === callID,
|
|
||||||
);
|
|
||||||
if (toolIndex === -1) continue;
|
|
||||||
|
|
||||||
|
const scan = (parts) => {
|
||||||
|
const toolIndex = parts.findIndex(isToolMatch);
|
||||||
|
if (toolIndex === -1) return undefined;
|
||||||
const texts = [];
|
const texts = [];
|
||||||
for (let i = 0; i < toolIndex; i++) {
|
for (let i = 0; i < toolIndex; i++) {
|
||||||
const part = parts[i];
|
const part = parts[i];
|
||||||
@@ -366,6 +518,21 @@ async function inspectCallContext(client, sessionID, callID) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { found: true, explanation: texts.length ? texts.join("\n") : undefined };
|
return { found: true, explanation: texts.length ? texts.join("\n") : undefined };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bevorzugt die Message aus dem Permission-Source (messageID), Fallback:
|
||||||
|
// alle Messages scannen.
|
||||||
|
if (messageID) {
|
||||||
|
const msg = messages.find((m) => m?.id === messageID);
|
||||||
|
if (Array.isArray(msg?.parts)) {
|
||||||
|
const hit = scan(msg.parts);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (!Array.isArray(msg?.parts)) continue;
|
||||||
|
const hit = scan(msg.parts);
|
||||||
|
if (hit) return hit;
|
||||||
}
|
}
|
||||||
return { found: false };
|
return { found: false };
|
||||||
} catch {
|
} catch {
|
||||||
@@ -373,22 +540,22 @@ async function inspectCallContext(client, sessionID, callID) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function explainAndNotify(client, p) {
|
async function explainAndNotify(ctx, p) {
|
||||||
const callID = p.tool?.callID ?? p.callID;
|
|
||||||
const rawCall = permissionCall(p);
|
const rawCall = permissionCall(p);
|
||||||
const type = permissionType(p) ?? "Tool";
|
const type = p.action ?? "Tool";
|
||||||
|
|
||||||
let explanation;
|
let explanation;
|
||||||
if (callID) {
|
if (p.callID) {
|
||||||
const ctx = await inspectCallContext(client, p.sessionID, callID);
|
const result = await inspectCallContext(ctx, p.sessionID, p.callID, p.messageID);
|
||||||
explanation = ctx.explanation;
|
explanation = result.explanation;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (explanation) {
|
if (explanation) {
|
||||||
appendLog({
|
appendLog({
|
||||||
ts: new Date().toISOString(),
|
ts: new Date().toISOString(),
|
||||||
event: "ask.explained",
|
event: "ask.explained",
|
||||||
permissionID: p.id,
|
permissionID: p.requestID,
|
||||||
|
callID: p.callID,
|
||||||
explanation: explanation.slice(0, 2000),
|
explanation: explanation.slice(0, 2000),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -400,55 +567,108 @@ async function explainAndNotify(client, p) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Plugin
|
// Plugin (V2: Plugin.define + default-Export, Hooks via setup registriert)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const ExplainPermissionsPlugin = async ({ client }) => {
|
async function setup(ctx) {
|
||||||
const hooks = {};
|
appendLog({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
event: "plugin-loaded",
|
||||||
|
version: PLUGIN_VERSION,
|
||||||
|
id: PLUGIN_ID,
|
||||||
|
opencode: ctx?.app?.version,
|
||||||
|
directory: ctx?.location?.directory,
|
||||||
|
});
|
||||||
|
|
||||||
appendLog({ ts: new Date().toISOString(), event: "plugin-loaded", version: PLUGIN_VERSION });
|
permissionSummary = readPermissionSummary(ctx?.location?.directory);
|
||||||
|
|
||||||
hooks.config = async (config) => {
|
|
||||||
permissionSummary = extractPermissionSummary(config);
|
|
||||||
if (DEBUG) debugLog("permission-summary", permissionSummary ?? "keine");
|
if (DEBUG) debugLog("permission-summary", permissionSummary ?? "keine");
|
||||||
};
|
|
||||||
|
|
||||||
// Beobachter-Status: liest input, ändert output.status nie.
|
if (INJECT) {
|
||||||
hooks["permission.ask"] = async (input) => {
|
// Agent-Loop inkl. Tool-Continuations (= V1 experimental.chat.system.transform)
|
||||||
debugLog("permission.ask-hook", input);
|
await ctx.session.hook("context", (event) => {
|
||||||
if (recordAsk(input)) void explainAndNotify(client, input);
|
const rule = buildSystemRule();
|
||||||
};
|
if (DEBUG) debugLog("system-rule", rule);
|
||||||
|
event.system.push({ type: "text", text: rule });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
hooks.event = async ({ event }) => {
|
// Feuert für allow UND ask nach Regel-Evaluation, vor Dialog/Ausführung;
|
||||||
const type = event?.type;
|
// explizites deny ruft den Hook nicht. Beobachter-Status: ändert effect nie.
|
||||||
const props = event?.properties ?? {};
|
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")) {
|
if (DEBUG && typeof type === "string" && type.startsWith("permission")) {
|
||||||
debugLog(`event:${type}`, props);
|
debugLog(`event:${type}`, props);
|
||||||
}
|
}
|
||||||
if (type === "permission.asked" || type === "permission.updated") {
|
if (type === "permission.v2.asked") {
|
||||||
if (recordAsk(props)) void explainAndNotify(client, props);
|
const rec = {
|
||||||
} else if (type === "permission.replied") {
|
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);
|
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);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// Stream-Abbruch (Cleanup) oder Transportfehler → stiller No-Op
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
if (ENFORCE) {
|
if (ENFORCE) {
|
||||||
hooks["tool.execute.before"] = async (input) => {
|
await ctx.tool.hook("execute.before", async (event) => {
|
||||||
if (!askedCallIDs.has(input.callID)) return;
|
const callID = event?.callID ?? event?.id;
|
||||||
const ctx = await inspectCallContext(client, input.sessionID, input.callID);
|
if (!callID || !askedCallIDs.has(callID)) return;
|
||||||
if (!ctx.found || ctx.explanation) return;
|
const result = await inspectCallContext(ctx, event.sessionID, callID, undefined);
|
||||||
askedCallIDs.delete(input.callID);
|
if (!result.found || result.explanation) return;
|
||||||
|
askedCallIDs.delete(callID);
|
||||||
throw new Error(EXPLAIN_FIRST_ERROR);
|
throw new Error(EXPLAIN_FIRST_ERROR);
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return hooks;
|
return () => controller.abort();
|
||||||
};
|
}
|
||||||
|
|
||||||
|
// Plugin.define ist die getypte Identitätsfunktion der V2-API. Der Import wird
|
||||||
|
// bewusst DYNAMISCH gehalten mit Fallback auf die Rohestform {id, setup}:
|
||||||
|
// Schlägt die Paketauflösung fehl (z. B. Einzel-Datei-Package außerhalb eines
|
||||||
|
// npm-Kontexts), wirf der statische Import das Laden der GESAMTEN Datei —
|
||||||
|
// dynamisch bleibt der Fleet-Load fail-soft und das Plugin trotzdem aktiv.
|
||||||
|
const definition = { id: PLUGIN_ID, setup };
|
||||||
|
|
||||||
|
export default await (async () => {
|
||||||
|
try {
|
||||||
|
const mod = await import("@opencode/plugin");
|
||||||
|
const define = mod?.Plugin?.define;
|
||||||
|
if (typeof define === "function") return define(definition);
|
||||||
|
} catch {
|
||||||
|
// Fallback unten
|
||||||
|
}
|
||||||
|
return definition;
|
||||||
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user