b6f42eb1a3bbdebbdc3e040686b094f52b6a0228
- Author
- Pavle <29902467+TheEdgeOfRage@users.noreply.github.com>
- Committer
- GitHub <noreply@github.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
2new file mode 100644
3index 0000000000000000000000000000000000000000..622b4a7349e8934e7195bb6d361a9211e323bbe3
4--- /dev/null
5+++ b/.github/workflows/ci.yaml
6@@ -0,0 +1,26 @@
7+name: "CI"
8+on:
9+ pull_request:
10+ types: [opened, synchronize, reopened]
11+
12+jobs:
13+ check:
14+ name: "Static analysis & Tests"
15+ runs-on: [self-hosted, generic]
16+ steps:
17+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
18+
19+ - uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2
20+ with:
21+ bun-version: latest
22+
23+ - run: bun install --frozen-lockfile
24+
25+ - name: Lint
26+ run: bun eslint .
27+
28+ - name: Typecheck
29+ run: bun tsc --noEmit
30+
31+ - name: Test
32+ run: bun test
33diff --git a/README.md b/README.md
34new file mode 100644
35index 0000000000000000000000000000000000000000..e49b04650a6799ba1512e1863fcc5907f75f787b
36--- /dev/null
37+++ b/README.md
38@@ -0,0 +1,99 @@
39+# opencode-policy-engine
40+
41+An [OpenCode](https://opencode.ai) plugin that automatically evaluates tool permission requests through a three-stage pipeline:
42+
43+1. **Deterministic regex rules** — instant allow/deny for known-safe or known-dangerous commands
44+2. **JSONL decision cache** — reuse previous LLM decisions for identical operations
45+3. **Haiku LLM judgment** — calls Claude Haiku for ambiguous cases
46+
47+If the pipeline decides "allow" or "deny", it auto-replies to OpenCode. If "ask", it leaves the prompt for you to decide manually.
48+
49+Also includes desktop notifications (Linux `notify-send`) for events that need your attention.
50+
51+## Install
52+
53+1. Create a loader file at `~/.config/opencode/plugins/policy-engine.ts`:
54+
55+```ts
56+import mod from "/path/to/policy-engine/src/index.ts"
57+export const PolicyEngine = mod.server
58+```
59+
60+2. Install dependencies:
61+
62+```sh
63+cd /path/to/policy-engine
64+bun install
65+```
66+
67+3. Set your OpenCode bash permissions to delegate to the plugin:
68+
69+```json
70+{
71+ "permission": {
72+ "bash": {
73+ "*": "ask"
74+ }
75+ }
76+}
77+```
78+
79+4. Restart OpenCode.
80+
81+## API key
82+
83+The plugin captures your Anthropic API key automatically from OpenCode's `chat.params` hook on the first LLM call. No extra configuration needed — it uses the same key OpenCode uses.
84+
85+The LLM model defaults to `claude-haiku-4-5-20251001`. Override with the `ANTHROPIC_SMALL_FAST_MODEL` environment variable.
86+
87+## How it works
88+
89+### Permission evaluation
90+
91+When OpenCode asks for permission (e.g., to run a bash command), the plugin intercepts the `permission.asked` event and runs the three-stage pipeline.
92+
93+**Stage 1 — Deterministic rules** check the command against:
94+- `HARD_ALLOW_PATTERNS`: strict anchored regexes for common safe commands (git status, ls, cat with relative paths, version checks, etc.)
95+- `CONFIG_ALLOW_PATTERNS`: broader prefix patterns for dev tooling (go test, make, bun, mvn, etc.) — still guarded by `SHELL_CONTROL_RE` which blocks shell operators (`;`, `&&`, `|`, `>`, etc.)
96+- `ASK_PATTERNS`: obviously dangerous patterns (sudo, curl|bash, rm -rf /)
97+
98+**Stage 2 — Cache** looks up a SHA256-keyed JSONL cache at `~/.config/opencode/hooks/cache/decisions.jsonl`. Cache entries are scoped to `POLICY_VERSION` and invalidated when rules change.
99+
100+**Stage 3 — Haiku LLM** sends the command to Claude Haiku with a security-focused prompt. The response is cached for future use.
101+
102+### Compound commands
103+
104+OpenCode splits compound commands (pipes, semicolons) into separate patterns. The plugin evaluates each individually — the most restrictive result wins (deny > ask > allow).
105+
106+### Session permissions
107+
108+The plugin monitors user messages for permission-granting language ("you can push", "go ahead and deploy") and extracts these as session-scoped permissions that influence LLM judgment.
109+
110+### Notifications
111+
112+Desktop notifications (Linux `notify-send`) fire for:
113+- **Permission needed** — only when the policy engine decides "ask" (auto-handled permissions are silent)
114+- **Session complete** — debounced, main agent only (subagents are filtered out)
115+- **Session error/cancelled**
116+- **Question** — when OpenCode's question tool needs your input
117+
118+Notifications are suppressed when the terminal is focused (supports Wayland compositors, X11, tmux, and WezTerm pane detection).
119+
120+## Logs
121+
122+All decisions are logged to `~/.config/opencode/hooks/logs/policy.jsonl` with timing, source (deterministic/cache/haiku), and the full decision.
123+
124+## Development
125+
126+```sh
127+bun test # run tests
128+bun tsc --noEmit # type check
129+```
130+
131+## Customizing rules
132+
133+Edit `src/rules.ts`:
134+- `HARD_ALLOW_PATTERNS` — strict regexes for instant allow
135+- `CONFIG_ALLOW_PATTERNS` — broader patterns for your dev tooling
136+- `ASK_PATTERNS` — dangerous patterns that always ask
137+- `POLICY_VERSION` — bump this when changing rules to invalidate the cache
138diff --git a/bun.lock b/bun.lock
139index 3afb9a75b646038f0671f5283d6fd95eaa6d4bdd..b36b81b5af9f854d263e61b775ce5b39daaf83e0 100644
140--- a/bun.lock
141+++ b/bun.lock
142@@ -8,24 +8,201 @@
143 "@opencode-ai/plugin": "^1.3.13",
144 },
145 "devDependencies": {
146+ "@eslint/js": "^10.0.1",
147 "bun-types": "latest",
148- "typescript": "^5.8",
149+ "eslint": "^10.1.0",
150+ "typescript": "^6.0.2",
151+ "typescript-eslint": "^8.58.0",
152 },
153 },
154 },
155 "packages": {
156diff --git a/eslint.config.js b/eslint.config.js
157new file mode 100644
158index 0000000000000000000000000000000000000000..b519dda02dd81259febb00781320261b091a692c
159--- /dev/null
160+++ b/eslint.config.js
161@@ -0,0 +1,17 @@
162+import eslint from "@eslint/js"
163+import tseslint from "typescript-eslint"
164+
165+export default tseslint.config(
166+ eslint.configs.recommended,
167+ ...tseslint.configs.recommended,
168+ {
169+ ignores: ["node_modules/"],
170+ },
171+ {
172+ rules: {
173+ "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
174+ "@typescript-eslint/no-explicit-any": "off",
175+ "no-empty": ["error", { allowEmptyCatch: true }],
176+ },
177+ },
178+)
179diff --git a/opencode-logo.png b/opencode-logo.png
180new file mode 100644
181index 0000000000000000000000000000000000000000..cf868c8e8711cf9e79638e17fedb2ae483047b74
182Binary files /dev/null and b/opencode-logo.png differ
183diff --git a/package.json b/package.json
184index dd0c2a9adbc1b34853ab1252bf2e79ea94cff25f..89b2af7187b97092874d14a6e146cd5eded9b831 100644
185--- a/package.json
186+++ b/package.json
187@@ -7,7 +7,10 @@
188 "@opencode-ai/plugin": "^1.3.13"
189 },
190 "devDependencies": {
191+ "@eslint/js": "^10.0.1",
192 "bun-types": "latest",
193- "typescript": "^5.8"
194+ "eslint": "^10.1.0",
195+ "typescript": "^6.0.2",
196+ "typescript-eslint": "^8.58.0"
197 }
198 }
199diff --git a/src/index.ts b/src/index.ts
200index 99b26f4e2e57e91d223e159a8f1b547e934cfdde..c1564715075c41fc7313aee7510191c12be72701 100644
201--- a/src/index.ts
202+++ b/src/index.ts
203@@ -210,19 +210,24 @@ const server: Plugin = async (ctx, _options) => {
204 event: async ({ event }) => {
205 const type = (event as { type: string }).type
206
207- // Session complete (idle with debounce)
208+ // Session complete (idle with debounce, main agent only)
209 if (type === "session.idle") {
210 const sid = (event as any).properties?.sessionID as string | undefined
211- if (!sid) { notify(title, "Session complete"); return }
212+ if (!sid) return
213
214 const seq = (idleSeq.get(sid) ?? 0) + 1
215 idleSeq.set(sid, seq)
216 const prev = idleTimers.get(sid)
217 if (prev) clearTimeout(prev)
218
219- idleTimers.set(sid, setTimeout(() => {
220+ idleTimers.set(sid, setTimeout(async () => {
221 idleTimers.delete(sid)
222- if (idleSeq.get(sid) === seq) notify(title, "Session complete")
223+ if (idleSeq.get(sid) !== seq) return
224+ try {
225+ const resp = await client.session.get({ path: { id: sid } })
226+ if (resp.data?.parentID) return // subagent — skip
227+ } catch {}
228+ notify(title, "Session complete")
229 }, IDLE_DELAY_MS))
230 return
231 }
232diff --git a/src/notify.ts b/src/notify.ts
233index 1019bd7aac18916e0458de8967f7482bbcda2b72..bdd430fff9fbd46a6e0f654764f9841462f98607 100644
234--- a/src/notify.ts
235+++ b/src/notify.ts
236@@ -1,4 +1,9 @@
237 import { execFile, execFileSync, execSync } from "child_process"
238+import { join } from "path"
239+import { existsSync } from "fs"
240+
241+const ICON_PATH = join(import.meta.dirname, "..", "opencode-logo.png")
242+const IS_MAC = process.platform === "darwin"
243
244 let lastNotificationId: number | null = null
245
246@@ -18,7 +23,49 @@ function execFileQuiet(cmd: string, args: readonly string[], timeoutMs = 500): s
247 }
248 }
249
250-// Focus detection — Linux only (Wayland + X11), with tmux/wezterm pane awareness.
251+// --- Focus detection ---
252+
253+const MAC_TERMINAL_APPS = new Set([
254+ "terminal", "iterm2", "ghostty", "wezterm", "alacritty", "kitty",
255+ "hyper", "warp", "tabby", "cursor", "visual studio code", "code",
256+ "code insiders", "zed", "rio",
257+])
258+
259+function normalizeMacAppName(name: string): string {
260+ return name.trim().toLowerCase().replace(/\.app$/i, "").replace(/\s+/g, " ")
261+}
262+
263+function getMacFrontmostApp(): string | null {
264+ return exec(`osascript -e 'tell application "System Events" to return name of first application process whose frontmost is true'`)
265+}
266+
267+function getExpectedMacTerminalApps(): Set<string> {
268+ const termProgram = process.env.TERM_PROGRAM
269+ ? normalizeMacAppName(process.env.TERM_PROGRAM)
270+ : ""
271+
272+ if (process.env.TMUX && (!termProgram || termProgram === "tmux" || termProgram === "screen")) {
273+ return MAC_TERMINAL_APPS
274+ }
275+
276+ const map: Record<string, string[]> = {
277+ apple_terminal: ["terminal"],
278+ iterm: ["iterm2"], iterm2: ["iterm2"],
279+ vscode: ["visual studio code", "code", "code insiders"],
280+ warpterminal: ["warp"],
281+ }
282+ if (map[termProgram]) return new Set(map[termProgram])
283+ if (termProgram) return new Set([termProgram])
284+ return MAC_TERMINAL_APPS
285+}
286+
287+function isMacTerminalFocused(): boolean {
288+ const app = getMacFrontmostApp()
289+ if (!app) return false
290+ return getExpectedMacTerminalApps().has(normalizeMacAppName(app))
291+}
292+
293+// Linux window ID detection
294
295 function getWaylandActiveWindowId(): string | null {
296 const env = process.env
297@@ -51,13 +98,15 @@ function getWaylandActiveWindowId(): string | null {
298 return null
299 }
300
301-function getActiveWindowId(): string | null {
302+function getLinuxActiveWindowId(): string | null {
303 if (process.env.WAYLAND_DISPLAY) return getWaylandActiveWindowId()
304 if (process.env.DISPLAY) return exec("xdotool getactivewindow")
305 return null
306 }
307
308-const cachedWindowId = getActiveWindowId()
309+const cachedWindowId = IS_MAC ? null : getLinuxActiveWindowId()
310+
311+// Multiplexer pane detection (shared by both platforms)
312
313 function isTmuxPaneActive(): boolean {
314 const pane = process.env.TMUX_PANE
315@@ -85,8 +134,15 @@ function isWezTermPaneActive(): boolean {
316
317 export function isTerminalFocused(): boolean {
318 try {
319+ if (IS_MAC) {
320+ if (!isMacTerminalFocused()) return false
321+ if (!isWezTermPaneActive()) return false
322+ if (process.env.TMUX) return isTmuxPaneActive()
323+ return true
324+ }
325+ // Linux
326 if (!cachedWindowId) return false
327- if (getActiveWindowId() !== cachedWindowId) return false
328+ if (getLinuxActiveWindowId() !== cachedWindowId) return false
329 if (!isWezTermPaneActive()) return false
330 if (process.env.TMUX) return isTmuxPaneActive()
331 return true
332@@ -95,18 +151,15 @@ export function isTerminalFocused(): boolean {
333 }
334 }
335