b6f42eb1a3bbdebbdc3e040686b094f52b6a0228

Author
Pavle <29902467+TheEdgeOfRage@users.noreply.github.com>
Committer
GitHub <noreply@github.com>
Date

Message

Add MacOS support and add Readme (#1)

* Improve notifications

* Add Readme

* Add MacOS support

* Add CI check on PR

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