ebc9e5d775fe5c8cb7a6f8a9ae9f906366919559

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

Port policy engine to pi agent

Diff

This diff is truncated to protect this page.

  1diff --git a/AGENTS.md b/AGENTS.md
  2index 2468bbaaf9b76e6f43e5058d979c4055cd3fa039..ba8f5e1ee05cb821153a06cac1cfc87cbe5d7d88 100644
  3--- a/AGENTS.md
  4+++ b/AGENTS.md
  5@@ -2,11 +2,11 @@
  6 
  7 ## Overview
  8 
  9-Security policy engine for OpenCode. Evaluates tool calls (primarily bash commands) against deterministic regex patterns and an LLM fallback to decide allow/deny/ask.
 10+Security policy engine for Pi. Evaluates tool calls, primarily Bash commands, against deterministic regex patterns and an LLM fallback to decide allow, deny, or ask.
 11 
 12 ## Project Structure
 13 
 14-- `src/index.ts` — Plugin entry point, OpenCode event handlers, and pipeline orchestration
 15+- `src/index.ts` — Pi extension entry point, `tool_call` handler, and pipeline orchestration
 16 - `src/rules.ts` — Regex patterns, `SHELL_CONTROL_RE`, `STDERR_REDIRECT_RE`, and the LLM policy prompt
 17 - `src/deterministic.ts` — Pattern matching logic against the rules
 18 - `src/llm.ts` — LLM policy evaluation, response parsing, and error fallback
 19diff --git a/README.md b/README.md
 20index 59c701b1a58c2225493cbe4b7b0adcd31eae6b1f..1fdbc7e51f0d54356829d7a2173f7682c4f0a02e 100644
 21--- a/README.md
 22+++ b/README.md
 23@@ -1,51 +1,38 @@
 24-# opencode-policy-engine
 25+# pi-policy-engine
 26 
 27-An [OpenCode](https://opencode.ai) plugin that automatically evaluates tool permission requests through a three-stage pipeline:
 28+A [Pi](https://pi.dev) extension that evaluates tool calls through a three-stage pipeline:
 29 
 30-1. **Deterministic regex rules** — instant allow or prompt for known-safe or known-dangerous commands
 31-2. **JSONL decision cache** — reuse previous LLM decisions for identical operations
 32-3. **LLM judgment** — calls OpenAI or a local OpenAI-compatible `llama-server` for ambiguous cases
 33+1. **Deterministic regex rules** — allow known-safe commands or require approval for known-dangerous commands
 34+2. **JSONL decision cache** — reuse LLM decisions for identical operations
 35+3. **LLM judgment** — call OpenAI or a local OpenAI-compatible `llama-server` for ambiguous calls
 36 
 37-If the pipeline decides "allow" or "deny", it auto-replies to OpenCode. If "ask", it leaves the prompt for you to decide manually.
 38+An `allow` decision lets Pi run the tool. A `deny` decision blocks it. An `ask` decision opens a confirmation dialog in interactive Pi and blocks the call when no UI is available.
 39 
 40-Also includes desktop notifications for events that need your attention.
 41+The extension also sends a desktop notification when a Pi session settles.
 42 
 43 ## Install
 44 
 45-1. Clone the repo and symlink the plugin into the OpenCode plugins directory:
 46+Install the checked-out package globally:
 47 
 48 ```sh
 49-git clone git@github.com:duneanalytics/opencode-policy-engine.git
 50-cd opencode-policy-engine && bun install
 51-
 52-# Linux
 53-mkdir -p ~/.config/opencode/plugins
 54-ln -s "$(pwd)/src/index.ts" ~/.config/opencode/plugins/policy-engine.ts
 55-
 56-# macOS
 57-mkdir -p ~/Library/Application\ Support/opencode/plugins
 58-ln -s "$(pwd)/src/index.ts" ~/Library/Application\ Support/opencode/plugins/policy-engine.ts
 59+cd /path/to/pi-policy-engine
 60+pi install .
 61 ```
 62 
 63-2. Set permissions to `"ask"` in your `opencode.json` so the plugin can intercept them:
 64+Pi records the local package in `~/.pi/agent/settings.json` and loads the extension from its `pi.extensions` manifest. Use `pi install -l .` to add it to the current project's `.pi/settings.json` instead. No Pi permission setting is required.
 65 
 66-```json
 67-{
 68-	"permission": {
 69-		"bash": {
 70-			"*": "ask"
 71-		}
 72-	}
 73-}
 74-```
 75+For development without installing it:
 76 
 77-3. Restart OpenCode.
 78+```sh
 79+bun install
 80+pi -e ./src/index.ts
 81+```
 82 
 83 ## Model backend
 84 
 85-By default, the plugin uses OpenAI. It captures the API key from OpenCode's `chat.params` hook when the active provider is OpenAI, and falls back to `OPENAI_API_KEY`. The model defaults to `gpt-5.4-nano`; override it with `OPENAI_SMALL_FAST_MODEL`.
 86+By default, the extension uses OpenAI. When Pi's active provider is OpenAI, it obtains that provider's configured key from Pi. It falls back to `OPENAI_API_KEY`. The model defaults to `gpt-5.4-nano`; override it with `OPENAI_SMALL_FAST_MODEL`.
 87 
 88-To use a local [`llama-server`](https://github.com/ggml-org/llama.cpp/tree/master/tools/server), create `~/.config/opencode/policy-engine.json`:
 89+To use a local [`llama-server`](https://github.com/ggml-org/llama.cpp/tree/master/tools/server), create `~/.pi/agent/policy-engine.json`:
 90 
 91 ```json
 92 {
 93@@ -57,13 +44,13 @@ To use a local [`llama-server`](https://github.com/ggml-org/llama.cpp/tree/maste
 94 }
 95 ```
 96 
 97diff --git a/bun.lock b/bun.lock
 98index b36b81b5af9f854d263e61b775ce5b39daaf83e0..8332726777e3e9db6d1b0170ba78905193e69dc5 100644
 99--- a/bun.lock
100+++ b/bun.lock
101@@ -4,19 +4,80 @@
102   "workspaces": {
103     "": {
104       "name": "opencode-policy-engine",
105-      "dependencies": {
106-        "@opencode-ai/plugin": "^1.3.13",
107-      },
108       "devDependencies": {
109+        "@earendil-works/pi-coding-agent": "^0.82.1",
110         "@eslint/js": "^10.0.1",
111         "bun-types": "latest",
112         "eslint": "^10.1.0",
113         "typescript": "^6.0.2",
114         "typescript-eslint": "^8.58.0",
115       },
116+      "peerDependencies": {
117+        "@earendil-works/pi-coding-agent": "*",
118+      },
119     },
120   },
121   "packages": {
122diff --git a/package.json b/package.json
123index 89b2af7187b97092874d14a6e146cd5eded9b831..dfbd20a12ac26ddcfbbf25c00cadf672ed05e3a0 100644
124--- a/package.json
125+++ b/package.json
126@@ -1,12 +1,17 @@
127 {
128-  "name": "opencode-policy-engine",
129+  "name": "pi-policy-engine",
130   "version": "3.0.0",
131   "type": "module",
132   "main": "src/index.ts",
133-  "dependencies": {
134-    "@opencode-ai/plugin": "^1.3.13"
135+  "keywords": ["pi-package"],
136+  "pi": {
137+    "extensions": ["./src/index.ts"]
138+  },
139+  "peerDependencies": {
140+    "@earendil-works/pi-coding-agent": "*"
141   },
142   "devDependencies": {
143+    "@earendil-works/pi-coding-agent": "^0.82.1",
144     "@eslint/js": "^10.0.1",
145     "bun-types": "latest",
146     "eslint": "^10.1.0",
147diff --git a/src/cache.ts b/src/cache.ts
148index e54bc3cabcae61a765bd6c2e65e7068162e89b40..90aa93a208157203e3e527a1c776d3f14fd1c0e5 100644
149--- a/src/cache.ts
150+++ b/src/cache.ts
151@@ -5,10 +5,8 @@ import type { Decision } from "./types"
152 import { POLICY_VERSION } from "./rules"
153 
154 let cacheFile = join(
155-  homedir(),
156-  ".config",
157-  "opencode",
158-  "hooks",
159+  process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"),
160+  "policy-engine",
161   "cache",
162   "decisions.jsonl",
163 )
164diff --git a/src/config.ts b/src/config.ts
165index 090e81c5f319c3c737fbeea9f31495d671adf453..d483c7994e6ddbf8e4fe755d4b0e87014a757268 100644
166--- a/src/config.ts
167+++ b/src/config.ts
168@@ -19,8 +19,8 @@ const DEFAULT_CONFIG: PolicyEngineConfig = {
169 }
170 
171 function configPath(): string {
172-  return process.env.OPENCODE_POLICY_ENGINE_CONFIG
173-    ?? join(homedir(), ".config", "opencode", "policy-engine.json")
174+  return process.env.PI_POLICY_ENGINE_CONFIG
175+    ?? join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), "policy-engine.json")
176 }
177 
178 function objectValue(value: unknown): Record<string, unknown> | undefined {
179diff --git a/src/index.ts b/src/index.ts
180index d5812fdf93d68cab46e2991254175601749ff31b..75ca445586c3dabb0ca0346e510d09fa2942cfd9 100644
181--- a/src/index.ts
182+++ b/src/index.ts
183@@ -1,69 +1,68 @@
184-import type { Plugin, PluginModule } from "@opencode-ai/plugin"
185-import type { Permission } from "@opencode-ai/sdk"
186-import { basename } from "path"
187-import { checkDeterministic } from "./deterministic"
188-import { normalizeRequest, cacheKey } from "./normalizer"
189+import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"
190+import { basename } from "node:path"
191+import { captureProviderCredentials } from "./api"
192 import { lookupCache, writeCache } from "./cache"
193+import { checkDeterministic } from "./deterministic"
194 import { evaluateWithLLM } from "./llm"
195-import { captureProviderCredentials } from "./api"
196 import { logDecision } from "./logger"
197+import { cacheKey, normalizeRequest } from "./normalizer"
198 import { notify } from "./notify"
199 import type { Decision } from "./types"
200 
201-// v2 PermissionRequest shape (event properties)
202-type PermissionAskedEvent = {
203-  type: "permission.asked"
204-  properties: {
205-    id: string
206-    sessionID: string
207-    permission: string
208-    patterns: string[]
209-    metadata: Record<string, unknown>
210-    always: string[]
211-    tool?: { messageID: string; callID: string }
212-  }
213+type ToolInput = Record<string, unknown>
214+type PipelineResult = { decision: Decision; source: string; rawResponse?: string; error?: string }
215+
216+function toolInput(input: unknown): ToolInput {
217+  return input && typeof input === "object" && !Array.isArray(input)
218+    ? { ...input as ToolInput }
219+    : {}
220 }
221 
222-function buildToolInput(
223-  toolType: string,
224-  pattern: string,
225-  metadata: Record<string, unknown>,
226-): Record<string, unknown> {
227-  switch (toolType) {
228-    case "bash":
229-      return { command: pattern }
230-    case "webfetch":
231-      return { url: pattern }
232-    default:
233-      return { ...metadata, pattern }
234-  }
235+function inputSummary(toolName: string, input: ToolInput): string {
236+  if (toolName === "bash" && typeof input.command === "string") return input.command
237+  return JSON.stringify(input).slice(0, 500)
238 }
239 
240-type PipelineResult = { decision: Decision; source: string; rawResponse?: string; error?: string }
241+function apiKey(value: unknown): string | undefined {
242+  if (!value || typeof value !== "object") return undefined
243+  const record = value as Record<string, unknown>
244+  if (typeof record.apiKey === "string") return record.apiKey
245+  if (typeof record.key === "string") return record.key
246+  return apiKey(record.auth) ?? apiKey(record.credentials)
247+}
248+
249+async function capturePiCredentials(ctx: ExtensionContext): Promise<void> {
250+  if (ctx.model?.provider !== "openai") return
251+  try {
252+    const auth = await ctx.modelRegistry.getProviderAuth("openai")
253+    const key = apiKey(auth)
254+    if (key) captureProviderCredentials(ctx.sessionManager.getSessionId(), { id: "openai", key })
255+  } catch {
256+    // OPENAI_API_KEY remains the fallback when Pi's credential lookup fails.
257+  }
258+}
259 
260 async function runPipelineSingle(
261   toolType: string,
262-  toolInput: Record<string, unknown>,
263+  input: ToolInput,
264   sessionId: string,
265 ): Promise<PipelineResult> {
266   const start = performance.now()
267+  const deterministic = checkDeterministic(toolType, input)
268+  const normalized = normalizeRequest(toolType, input)
269 
270-  // Stage 1: deterministic
271-  const det = checkDeterministic(toolType, toolInput)
272-  if (det) {
273+  if (deterministic) {
274     logDecision({
275       toolType,
276-      normalized: normalizeRequest(toolType, toolInput),
277-      decision: det,
278+      normalized,
279+      decision: deterministic,
280       source: "deterministic",
281       timingMs: performance.now() - start,
282       sessionId,
283diff --git a/src/logger.ts b/src/logger.ts
284index 3c87ca5b2192ba332feb5a12460fa3bca2c6fddf..f6af6a0e0dafac25949e2996393dd41b7255c461 100644
285--- a/src/logger.ts
286+++ b/src/logger.ts
287@@ -3,7 +3,11 @@ import { homedir } from "os"
288 import { join } from "path"
289 import type { Decision } from "./types"
290 
291-const LOG_DIR = join(homedir(), ".config", "opencode", "hooks", "logs")
292+const LOG_DIR = join(
293+  process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"),
294+  "policy-engine",
295+  "logs",
296+)
297 const LOG_FILE = join(LOG_DIR, "policy.jsonl")
298 
299 let enabled = true
300diff --git a/src/notify.ts b/src/notify.ts
301index 3126d90e9f3c46cac44bee215da0de0220f92076..81e8c861714d1b6d5007b588d0dff03e5fbedbc9 100644
302--- a/src/notify.ts
303+++ b/src/notify.ts
304@@ -1,14 +1,8 @@
305 import { execFile } from "child_process"
306-import { existsSync } from "fs"
307-import { join } from "path"
308-
309-const ICON_PATH = join(import.meta.dirname, "..", "opencode-logo.png")
310 const IS_MAC = process.platform === "darwin"
311 
312 function notifyLinux(title: string, message: string, timeout: number): void {
313-  const args = ["--app-name", "opencode", "--expire-time", String(timeout * 1000)]
314-  if (existsSync(ICON_PATH)) args.push("--icon", ICON_PATH)
315-  args.push("--", title, message)
316+  const args = ["--app-name", "pi", "--expire-time", String(timeout * 1000), "--", title, message]
317 
318   execFile("notify-send", args, () => {})
319 }
320diff --git a/src/rules.ts b/src/rules.ts
321index 6aa8470615416a9a46eef3e734d2cc2aee9b060f..0565d525c0ea1b4d76d153602440f411137ed1df 100644
322--- a/src/rules.ts
323+++ b/src/rules.ts
324@@ -145,13 +145,10 @@ export const ASK_PATTERNS: RegExp[] = [
325   /\brm\s+-rf\s+\/\*/,
326 ];
327 
328-// Shell operators that can still arrive after OpenCode's tree-sitter splitting.
329-// Pipes (|), logical ops (&&, ||, ;), command substitution ($(), ``), and
330-// background (&) are split into separate command nodes by tree-sitter — they
331-// never reach the policy engine as part of a single pattern.
332-// Redirections (>, <) DO arrive (via redirected_statement parent node).
333-// Parameter expansion (${) stays in token text.
334-export const SHELL_CONTROL_RE = /(>|<|\$\{)/;
335+// Pi passes the full bash command to the extension. Reject shell syntax from
336+// deterministic allow patterns so each complete compound command reaches the
337+// LLM policy stage instead.
338+export const SHELL_CONTROL_RE = /[|;&`<>\r\n]|\$\(|\$\{/;
339 
340 export const LLM_POLICY_PROMPT = `Classify this tool call as allow or ask
341 
342diff --git a/test/api.test.ts b/test/api.test.ts
343index 4184418f0a46e0bc4d580926daed77b4719dec4a..a7ab67291b4ba4e7fdfa9c608729503d0860e892 100644
344--- a/test/api.test.ts
345+++ b/test/api.test.ts
346@@ -8,7 +8,7 @@ const originalFetch = globalThis.fetch
347 const envKeys = [
348   "OPENAI_API_KEY",
349   "OPENAI_SMALL_FAST_MODEL",
350-  "OPENCODE_POLICY_ENGINE_CONFIG",
351+  "PI_POLICY_ENGINE_CONFIG",
352 ] as const
353 const originalEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]]))
354 let tempDir: string | undefined
355@@ -34,7 +34,7 @@ function headers(init: RequestInit): Record<string, string> {
356 }
357 
358 function useLocalBackend(llamaServer?: { baseUrl: string; model: string }) {
359-  writeFileSync(process.env.OPENCODE_POLICY_ENGINE_CONFIG!, JSON.stringify({
360+  writeFileSync(process.env.PI_POLICY_ENGINE_CONFIG!, JSON.stringify({
361     modelBackend: "local",
362     llamaServer,
363   }))
364@@ -44,7 +44,7 @@ beforeEach(() => {
365   clearCapturedCredentials()
366   for (const key of envKeys) delete process.env[key]
367   tempDir = mkdtempSync(join(tmpdir(), "policy-engine-"))
368-  process.env.OPENCODE_POLICY_ENGINE_CONFIG = join(tempDir, "config.json")
369+  process.env.PI_POLICY_ENGINE_CONFIG = join(tempDir, "config.json")
370 })
371 
372 afterEach(() => {
373@@ -123,7 +123,7 @@ describe("callLLM", () => {
374     await expect(callLLM([{ role: "user", content: "test" }], 64, "s1")).resolves.toBe("fallback")
375   })
376 
377-  test("ignores OpenCode OAuth placeholders", async () => {
378+  test("ignores placeholder credentials", async () => {
379     captureProviderCredentials("s1", { id: "openai", key: "opencode-oauth-dummy-key" })
380     mockFetch(() => {
381       throw new Error("fetch should not be called")
382diff --git a/test/deterministic.test.ts b/test/deterministic.test.ts
383index dbc33eb9ab152cd32cfe5cb85b0cabdb6c58452d..35be721a2b559611447b15bdc93e097defa57322 100644
384--- a/test/deterministic.test.ts
385+++ b/test/deterministic.test.ts
386@@ -66,11 +66,16 @@ describe("hard allow — accepts simple safe commands", () => {
387 })
388 
389 describe("shell operators block deterministic allow", () => {
390-  // Only >, <, ${ can arrive — OpenCode's tree-sitter splits |, ;, &&, ||, &, $(), ``
391   const cases = [
392     "cat foo > /etc/passwd",
393     "cat foo < /etc/shadow",
394     'echo ${HOME}',
395+    "git status | rm -rf /",
396+    "git status; rm -rf /",
397+    "git status && rm -rf /",
398+    "git status\nrm -rf /",
399+    "git status $(rm -rf /)",
400+    "git status `rm -rf /`",
401   ]
402   for (const cmd of cases) {
403     test(cmd, () => {
404diff --git a/test/extension.test.ts b/test/extension.test.ts
405new file mode 100644
406index 0000000000000000000000000000000000000000..6af2a120578878980d15f34ef34a8b805dc36892
407--- /dev/null
408+++ b/test/extension.test.ts
409@@ -0,0 +1,51 @@
410+import { describe, expect, test } from "bun:test"
411+import PolicyEngine from "../src/index"
412+
413+type ToolCallHandler = (event: { toolName: string; input: Record<string, unknown> }, ctx: any) => Promise<unknown>
414+
415+function loadToolCallHandler(): ToolCallHandler {
416+  let handler: ToolCallHandler | undefined
417+  PolicyEngine({
418+    on(event: string, callback: ToolCallHandler) {
419+      if (event === "tool_call") handler = callback
420+    },
421+  } as any)
422+  if (!handler) throw new Error("tool_call handler was not registered")
423+  return handler
424+}
425+
426+function context(hasUI: boolean, confirm = async () => false) {
427+  return {
428+    hasUI,
429+    model: undefined,
430+    modelRegistry: {},
431+    sessionManager: { getSessionId: () => "test-session" },
432+    ui: { confirm },
433+  }
434+}
435+
436+describe("Pi policy extension", () => {
437+  test("allows deterministically safe bash commands", async () => {
438+    const handler = loadToolCallHandler()
439+    await expect(handler({ toolName: "bash", input: { command: "git status" } }, context(false)))
440+      .resolves.toBeUndefined()
441+  })
442+
443+  test("blocks approval-required commands without a UI", async () => {
444+    const handler = loadToolCallHandler()
445+    await expect(handler({ toolName: "bash", input: { command: "sudo apt install foo" } }, context(false)))
446+      .resolves.toMatchObject({ block: true })
447+  })
448+
449+  test("allows a user-approved command", async () => {
450+    const handler = loadToolCallHandler()
451+    let prompted = false
452+    const confirm = async () => {
453+      prompted = true
454+      return true
455+    }
456+    await expect(handler({ toolName: "bash", input: { command: "sudo apt install foo" } }, context(true, confirm)))
457+      .resolves.toBeUndefined()
458+    expect(prompted).toBe(true)
459+  })
460+})
461diff --git a/test/llm.test.ts b/test/llm.test.ts
462index ac94471bdb6ddceee2eb01d11519085be62265c7..aa278138ce7ee8d8323b242b9675844682b3a105 100644
463--- a/test/llm.test.ts
464+++ b/test/llm.test.ts
465@@ -21,7 +21,7 @@ async function serverUp(): Promise<boolean> {
466 const up = await serverUp()
467 
468 const configPath = join(tmpdir(), `policy-engine-llm-test-${process.pid}.json`)
469-const originalConfigEnv = process.env.OPENCODE_POLICY_ENGINE_CONFIG
470+const originalConfigEnv = process.env.PI_POLICY_ENGINE_CONFIG
471 
472 beforeAll(() => {
473   if (!up) return
474@@ -29,13 +29,13 @@ beforeAll(() => {
475     modelBackend: "local",
476     llamaServer: { baseUrl: BASE_URL, model: MODEL },
477   }))
478-  process.env.OPENCODE_POLICY_ENGINE_CONFIG = configPath
479+  process.env.PI_POLICY_ENGINE_CONFIG = configPath
480 })
481 
482 afterAll(() => {
483   if (!up) return
484-  if (originalConfigEnv === undefined) delete process.env.OPENCODE_POLICY_ENGINE_CONFIG
485-  else process.env.OPENCODE_POLICY_ENGINE_CONFIG = originalConfigEnv
486+  if (originalConfigEnv === undefined) delete process.env.PI_POLICY_ENGINE_CONFIG
487+  else process.env.PI_POLICY_ENGINE_CONFIG = originalConfigEnv
488   unlinkSync(configPath)
489 })
490