ebc9e5d775fe5c8cb7a6f8a9ae9f906366919559
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
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