7b7b1d24ae8aea40e0dc77548624ffdaf3156208
- 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 d3e0d1e817eaeb7f70a693ef871bb4db39701bfa..4dc50c1befccb890aa3c2ed984e264b2622629f1 100644
3--- a/AGENTS.md
4+++ b/AGENTS.md
5@@ -2,22 +2,20 @@
6
7 ## Purpose
8
9-This package provides policy plugins for Pi and OpenCode. It combines host-specific prechecks, deterministic rules, a JSONL decision cache, and an LLM reviewer. Decisions are `allow`, `deny`, or `ask`.
10+This package provides a policy plugin for Pi. It combines host-specific prechecks, deterministic rules, a JSONL decision cache, and an LLM reviewer. Decisions are `allow`, `deny`, or `ask`.
11
12 ## Layout
13
14-- `src/core/` — shared rules, deterministic checks, pipeline, reviewer, providers, cache, audit, normalization, and configuration.
15-- `src/pi/` — Pi extension, static permissions, Bash parsing, credentials, UI, and session Bash overrides.
16-- `src/opencode/` — OpenCode plugin, configuration, credentials, and permission event conversion.
17-- `test/core/`, `test/pi/`, `test/opencode/` — unit and extension tests.
18+- `src/core/` — shared rules, deterministic checks, pipeline, reviewer, llama.cpp provider, cache, audit, normalization, and configuration.
19+- `src/pi/` — Pi extension, static permissions, Bash parsing, UI, and session Bash overrides.
20+- `test/core/`, `test/pi/` — unit and extension tests.
21
22-The package root exports the OpenCode plugin. `./pi` exports the Pi extension. `src/index.ts` re-exports `./pi/index` for Pi extension loading.
23+The package root and `./pi` export the Pi extension. `src/index.ts` re-exports `./pi/index` for Pi extension loading.
24
25 ## Policy flow
26
27 Pi evaluates session Bash overrides and static permissions before deterministic rules. It parses valid Bash into command nodes before evaluation. Unparsed Bash does not receive a deterministic allow and falls back to LLM review.
28
29-OpenCode converts each `permission.asked` pattern to a policy request. It has no Pi prechecks.
30
31diff --git a/README.md b/README.md
32index 3184a8c26fd2a27daf4ee187960acf690196a22c..1daee77771150311dd4ecf52139fc5ca5801edb2 100644
33--- a/README.md
34+++ b/README.md
35@@ -1,6 +1,6 @@
36 # agent-policy-engine
37
38-A policy engine for Pi and OpenCode. It evaluates tool operations in this order:
39+A policy engine for Pi. It evaluates tool operations in this order:
40
41 1. Host prechecks
42 2. Deterministic policy rules
43@@ -24,31 +24,26 @@ bun install
44 pi -e ./src/pi/index.ts
45 ```
46
47-### OpenCode
48-
49-Install this package as an OpenCode plugin. The package root exports `src/opencode/index.ts`.
50-
51-OpenCode must ask for operations that this policy engine should evaluate. OpenCode operations that native permissions allow do not reach the plugin.
52-
53 ## Reviewer configuration
54
55diff --git a/bun.lock b/bun.lock
56index 75cbb01c0ce84273bb6c4f2a56ab2dfe24b85953..a2298473651561f90a6c4f14cf9c3915dd551850 100644
57--- a/bun.lock
58+++ b/bun.lock
59@@ -5,7 +5,6 @@
60 "": {
61 "name": "agent-policy-engine",
62 "dependencies": {
63- "@opencode-ai/plugin": "1.18.22",
64 "tree-sitter-bash": "0.25.1",
65 "web-tree-sitter": "0.26.13",
66 },
67@@ -23,8 +22,6 @@
68 },
69 },
70 "packages": {
71- "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
72-
73diff --git a/package.json b/package.json
74index d31ca83b817017de319db7f2cac6a701a61e151e..ae2566315559f0718a2170b77083f9e09302ba22 100644
75--- a/package.json
76+++ b/package.json
77@@ -2,9 +2,9 @@
78 "name": "agent-policy-engine",
79 "version": "3.0.0",
80 "type": "module",
81- "main": "./src/opencode/index.ts",
82+ "main": "./src/pi/index.ts",
83 "exports": {
84- ".": "./src/opencode/index.ts",
85+ ".": "./src/pi/index.ts",
86 "./pi": "./src/pi/index.ts",
87 "./core": "./src/core/index.ts"
88 },
89@@ -28,7 +28,6 @@
90 "typescript-eslint": "^8.58.0"
91 },
92 "dependencies": {
93- "@opencode-ai/plugin": "1.18.22",
94 "tree-sitter-bash": "0.25.1",
95 "web-tree-sitter": "0.26.13"
96 }
97diff --git a/src/core/config.ts b/src/core/config.ts
98index b7437a880941e5048add7bd7fc402850f518de99..52276a12a5d0f11804cdfac620ecba34a76d33ae 100644
99--- a/src/core/config.ts
100+++ b/src/core/config.ts
101@@ -1,8 +1,4 @@
102-import type { LlamaReasoningFormat, OpenAIReasoningEffort, ReviewerConfig } from "./types"
103-
104-const DEFAULT_OPENAI_MODEL = "gpt-5.4-nano"
105-const DEFAULT_LLAMA_BASE_URL = "http://127.0.0.1:9931"
106-const DEFAULT_LLAMA_MODEL = "reviewer"
107+import type { LlamaReasoningFormat, PiReasoningEffort, ReviewerConfig } from "./types"
108
109 function objectValue(value: unknown): Record<string, unknown> | undefined {
110 return value && typeof value === "object" && !Array.isArray(value)
111@@ -14,13 +10,10 @@ function stringValue(value: unknown): string | undefined {
112 return typeof value === "string" && value.trim() ? value.trim() : undefined
113 }
114
115-function apiKeyEnvironment(value: unknown, field: string): string | undefined {
116- const name = stringValue(value)
117- if (!name) return undefined
118- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
119- throw new Error(`${field} must be an environment variable name`)
120- }
121- return name
122+function booleanValue(value: unknown, field: string, fallback: boolean): boolean {
123+ if (value === undefined) return fallback
124+ if (typeof value === "boolean") return value
125+ throw new Error(`${field} must be a boolean`)
126 }
127
128 function llamaReasoningFormat(value: unknown): LlamaReasoningFormat {
129@@ -29,66 +22,41 @@ function llamaReasoningFormat(value: unknown): LlamaReasoningFormat {
130 throw new Error('reviewer.reasoningFormat must be "none", "deepseek", or "deepseek-legacy"')
131 }
132
133-function openAIReasoningEffort(value: unknown): OpenAIReasoningEffort | undefined {
134+function reasoningEffort(value: unknown): PiReasoningEffort | undefined {
135 if (value === undefined) return undefined
136- if (value === "none" || value === "low" || value === "medium" || value === "high" || value === "xhigh" || value === "max") {
137+ if (value === "none" || value === "minimal" || value === "low" || value === "medium" || value === "high" || value === "xhigh" || value === "max") {
138 return value
139 }
140- throw new Error('reviewer.reasoningEffort must be "none", "low", "medium", "high", "xhigh", or "max"')
141-}
142-
143-function booleanValue(value: unknown, field: string, fallback: boolean): boolean {
144- if (value === undefined) return fallback
145- if (typeof value === "boolean") return value
146- throw new Error(`${field} must be a boolean`)
147-}
148-
149-function openAIConfig(value: Record<string, unknown>): ReviewerConfig {
150- return {
151- kind: "openai",
152- model: stringValue(value.model) ?? process.env.OPENAI_SMALL_FAST_MODEL ?? DEFAULT_OPENAI_MODEL,
153- reasoningEffort: openAIReasoningEffort(value.reasoningEffort),
154- }
155-}
156-
157-function llamaConfig(value: Record<string, unknown>): ReviewerConfig {
158- return {
159- kind: "llama.cpp",
160- baseUrl: stringValue(value.baseUrl) ?? DEFAULT_LLAMA_BASE_URL,
161- model: stringValue(value.model) ?? DEFAULT_LLAMA_MODEL,
162- enableThinking: booleanValue(value.enableThinking, "reviewer.enableThinking", false),
163- reasoningFormat: llamaReasoningFormat(value.reasoningFormat),
164- }
165-}
166-
167-function compatibleConfig(value: Record<string, unknown>, field: string): ReviewerConfig {
168- const baseUrl = stringValue(value.baseUrl)
169- const model = stringValue(value.model)
170- if (!baseUrl) throw new Error(`${field}.baseUrl must be a non-empty string`)
171- if (!model) throw new Error(`${field}.model must be a non-empty string`)
172- return {
173- kind: "openai-compatible",
174- baseUrl,
175- model,
176- apiKeyEnv: apiKeyEnvironment(value.apiKeyEnv, `${field}.apiKeyEnv`),
177- }
178+ throw new Error('reviewer.reasoningEffort must be "none", "minimal", "low", "medium", "high", "xhigh", or "max"')
179 }
180
181 export function parseReviewerConfig(value: unknown): ReviewerConfig {
182 const data = objectValue(value)
183- if (!data) return openAIConfig({})
184-
185- const reviewer = objectValue(data.reviewer)
186- if (reviewer) {
187- const kind = reviewer.kind
188- if (kind === "openai") return openAIConfig(reviewer)
189- if (kind === "llama.cpp") return llamaConfig(reviewer)
190- if (kind === "openai-compatible") return compatibleConfig(reviewer, "reviewer")
191- throw new Error('reviewer.kind must be "openai", "llama.cpp", or "openai-compatible"')
192+ const reviewer = data && objectValue(data.reviewer)
193+ if (!reviewer) {
194+ return {
195+ kind: "pi",
196+ provider: "openai-codex",
197+ model: "gpt-5.6-luna",
198+ reasoningEffort: "minimal",
199+ }
200+ }
201diff --git a/src/core/providers.ts b/src/core/providers.ts
202index aecf623ff47a23d50dce4099355aaadbd37b7a76..52c526e50e2bcae2e6a7534dc7b20989e93b56ce 100644
203--- a/src/core/providers.ts
204+++ b/src/core/providers.ts
205@@ -1,12 +1,6 @@
206+import type { ChatMessage } from "./review"
207 import type { ReviewerConfig } from "./types"
208
209-export type ChatMessage = {
210- role: "system" | "user"
211- content: string
212-}
213-
214-export type ApiKeyResolver = (sessionId?: string) => string
215-
216 type LlamaResponse = {
217 choices?: {
218 message?: {
219@@ -19,103 +13,39 @@ function textValue(value: unknown): string | undefined {
220 return typeof value === "string" && value.trim() ? value : undefined
221 }
222
223-function llamaResponseText(data: LlamaResponse): string | undefined {
224- const choice = data.choices?.[0]
225- return textValue(choice?.message?.content)
226-}
227-
228 function completionUrl(baseUrl: string): string {
229 const normalized = baseUrl.replace(/\/+$/, "")
230 const root = normalized.endsWith("/v1") ? normalized : `${normalized}/v1`
231 return `${root}/chat/completions`
232 }
233
234-async function responseText(response: Response, name: string): Promise<string> {
235- if (!response.ok) throw new Error(`${name} API ${response.status}`)
236- const data = await response.json() as { choices?: { message?: { content?: string | null } }[] }
237- return data.choices?.[0]?.message?.content ?? ""
238-}
239-
240-async function callOpenAI(
241- baseUrl: string,
242- model: string,
243- messages: ChatMessage[],
244- maxTokens: number,
245- apiKey?: string,
246- reasoningEffort?: "none" | "low" | "medium" | "high" | "xhigh" | "max",
247-): Promise<string> {
248- const response = await fetch(completionUrl(baseUrl), {
249- method: "POST",
250- headers: {
251- "content-type": "application/json",
252- ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
253- },
254- body: JSON.stringify({
255- model,
256- max_completion_tokens: maxTokens,
257- messages,
258- temperature: 0,
259- ...(reasoningEffort && { reasoning_effort: reasoningEffort }),
260- }),
261- })
262- return responseText(response, "OpenAI")
263+async function responseError(response: Response): Promise<never> {
264+ const detail = (await response.text()).trim().slice(0, 1000)
265+ throw new Error(`llama-server API ${response.status}${detail ? `: ${detail}` : ""}`)
266 }
267
268-async function callLlama(
269- baseUrl: string,
270- model: string,
271+export async function callLlamaReviewer(
272+ config: Extract<ReviewerConfig, { kind: "llama.cpp" }>,
273 messages: ChatMessage[],
274 maxTokens: number,
275- enableThinking: boolean,
276- reasoningFormat: "none" | "deepseek" | "deepseek-legacy",
277 ): Promise<string> {
278- const response = await fetch(completionUrl(baseUrl), {
279+ const response = await fetch(completionUrl(config.baseUrl), {
280 method: "POST",
281 headers: { "content-type": "application/json" },
282 body: JSON.stringify({
283- model,
284+ model: config.model,
285 max_tokens: maxTokens,
286 messages,
287 stream: false,
288 temperature: 0,
289 response_format: { type: "json_object" },
290- chat_template_kwargs: { enable_thinking: enableThinking },
291- reasoning_format: reasoningFormat,
292+ chat_template_kwargs: { enable_thinking: config.enableThinking },
293+ reasoning_format: config.reasoningFormat,
294 }),
295 })
296- if (!response.ok) throw new Error(`llama-server API ${response.status}`)
297- const text = llamaResponseText(await response.json() as LlamaResponse)
298+ if (!response.ok) return responseError(response)
299+ const data = await response.json() as LlamaResponse
300+ const text = textValue(data.choices?.[0]?.message?.content)
301 if (!text) throw new Error("llama-server response had no generated text")
302 return text
303 }
304-
305diff --git a/src/core/review.ts b/src/core/review.ts
306index 5c10e3d6a2fda2a118130c72eda9a4cc5ee4fc7e..0747e1b142d86dc4ff924481e4bf81f9fc71634d 100644
307--- a/src/core/review.ts
308+++ b/src/core/review.ts
309@@ -1,5 +1,5 @@
310+import { callLlamaReviewer } from "./providers"
311 import { llmPolicyPrompt } from "./rules"
312-import { callReviewer, type ApiKeyResolver } from "./providers"
313 import {
314 isDecisionCategory,
315 type Decision,
316@@ -10,6 +10,18 @@ import {
317 type ReviewerConfig,
318 } from "./types"
319
320+export type ChatMessage = {
321+ role: "system" | "user"
322+ content: string
323+}
324+
325+export type PiReviewerCaller = (
326+ config: Extract<ReviewerConfig, { kind: "pi" }>,
327+ messages: ChatMessage[],
328+ maxTokens: number,
329+ context: PolicyContext,
330+) => Promise<string>
331+
332 const ASK_FALLBACK: Decision = {
333 decision: "ask",
334 reason: "Policy engine error",
335@@ -35,6 +47,10 @@ export function parseLLMResponse(content: string): Decision | undefined {
336 }
337 }
338
339+function errorReason(error: unknown): string {
340+ return `Policy engine error: ${error instanceof Error ? error.message : String(error)}`
341+}
342+
343 function reviewRequest(request: PolicyRequest, context: PolicyContext): string {
344 return `<tool_request>\n${JSON.stringify({
345 cwd: context.cwd,
346@@ -45,22 +61,19 @@ function reviewRequest(request: PolicyRequest, context: PolicyContext): string {
347
348 export function createPolicyReviewer(
349 config: ReviewerConfig,
350- resolveApiKey: ApiKeyResolver,
351+ callPiReviewer: PiReviewerCaller,
352 externalDirectories?: readonly string[],
353 ): PolicyReviewer {
354 return {
355 async evaluate(request: PolicyRequest, context: PolicyContext): Promise<LLMEvaluationResult> {
356 try {
357- const rawResponse = await callReviewer(
358- config,
359- [
360- { role: "system", content: llmPolicyPrompt(externalDirectories) },
361- { role: "user", content: reviewRequest(request, context) },
362- ],
363- 512,
364- context.sessionId,
365- resolveApiKey,
366- )
367+ const messages = [
368+ { role: "system" as const, content: llmPolicyPrompt(externalDirectories) },
369+ { role: "user" as const, content: reviewRequest(request, context) },
370+ ]
371+ const rawResponse = config.kind === "pi"
372+ ? await callPiReviewer(config, messages, 512, context)
373+ : await callLlamaReviewer(config, messages, 512)
374 const decision = parseLLMResponse(rawResponse)
375 if (decision) return { decision, rawResponse }
376 return {
377@@ -68,10 +81,11 @@ export function createPolicyReviewer(
378 rawResponse,
379 error: "Failed to parse response JSON",
380 }
381- } catch {
382+ } catch (error) {
383+ const reason = errorReason(error)
384 return {
385- decision: ASK_FALLBACK,
386- error: "Policy engine error",
387+ decision: { ...ASK_FALLBACK, reason },
388+ error: reason,
389 }
390 }
391 },
392diff --git a/src/core/rules.ts b/src/core/rules.ts
393index 2a04b35b333d744b5f73cb61c12d5c5afeed36fe..da132e85e378f9282c8fe34bbf5bb5eedd35483f 100644
394--- a/src/core/rules.ts
395+++ b/src/core/rules.ts
396@@ -114,8 +114,7 @@ export const HARD_ALLOW_PATTERNS: RegExp[] = [
397 /^(ba)?sh\s+-n(?:\s+\S+)*\s*$/,
398 ];
399
400-// Broader prefix patterns ported from the OpenCode config.
401-// Still guarded by SHELL_CONTROL_RE (no >, <, ${).
402+// Broader prefix patterns remain guarded by SHELL_CONTROL_RE (no >, <, ${).
403 export const CONFIG_ALLOW_PATTERNS: RegExp[] = [
404 // Tools without stricter HARD_ALLOW equivalents
405 /^echo\s/,
406diff --git a/src/core/types.ts b/src/core/types.ts
407index 7acf2fe6c34f03073af6228d2e740b22c8548965..f7161620a65fa476168a042a4591fcac863ceadd 100644
408--- a/src/core/types.ts
409+++ b/src/core/types.ts
410@@ -37,22 +37,6 @@ export type Decision = {
411 category: DecisionCategory
412 }
413
414-export type PolicyRequest = {
415- toolName: string
416- input: Record<string, unknown>
417-}
418-
419-export type PolicyContext = {
420- sessionId?: string
421- cwd?: string
422- signal?: AbortSignal
423-}
424-
425-export type PolicyPrecheck = {
426- source: "session" | "static"
427- decide(request: PolicyRequest, context: PolicyContext): Promise<Decision | undefined>
428-}
429-
430 export type LLMEvaluationResult = {
431 decision: Decision
432 rawResponse?: string
433@@ -63,11 +47,16 @@ export type PolicyReviewer = {
434 evaluate(request: PolicyRequest, context: PolicyContext): Promise<LLMEvaluationResult>
435 }
436
437+export type PiReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"
438 export type LlamaReasoningFormat = "none" | "deepseek" | "deepseek-legacy"
439-export type OpenAIReasoningEffort = "none" | "low" | "medium" | "high" | "xhigh" | "max"
440
441 export type ReviewerConfig =
442- | { kind: "openai"; model: string; reasoningEffort?: OpenAIReasoningEffort }
443+ | {
444+ kind: "pi"
445+ provider: string
446+ model: string
447+ reasoningEffort?: PiReasoningEffort
448+ }
449 | {
450 kind: "llama.cpp"
451 baseUrl: string
452@@ -75,7 +64,6 @@ export type ReviewerConfig =
453 enableThinking: boolean
454 reasoningFormat: LlamaReasoningFormat
455 }
456- | { kind: "openai-compatible"; baseUrl: string; model: string; apiKeyEnv?: string }
457
458 export type CacheEntry = {
459 key: string
460@@ -113,3 +101,19 @@ export type PolicyEvaluation = {
461 error?: string
462 approvalRequests?: PolicyRequest[]
463 }
464+
465+export type PolicyRequest = {
466+ toolName: string
467+ input: Record<string, unknown>
468+}
469+
470+export type PolicyContext = {
471+ sessionId?: string
472+ cwd?: string
473+ signal?: AbortSignal
474+}
475+
476+export type PolicyPrecheck = {
477+ source: Exclude<DecisionSource, "llm" | "cache" | "deterministic">
478+ decide(request: PolicyRequest, context: PolicyContext): Promise<Decision | undefined>
479+}
480diff --git a/src/opencode/config.ts b/src/opencode/config.ts
481deleted file mode 100644
482index fbe7283c9b343ce04da4a067d8036370d77430a6..0000000000000000000000000000000000000000
483--- a/src/opencode/config.ts
484+++ /dev/null
485@@ -1,29 +0,0 @@
486-import { existsSync, readFileSync } from "node:fs"
487-import { homedir } from "node:os"
488-import { join } from "node:path"
489-import { parseReviewerConfig } from "../core/config"
490-import type { ReviewerConfig } from "../core/types"
491-
492-export type OpenCodePolicyEngineConfig = {
493- reviewer: ReviewerConfig
494-}
495-
496-function configPath(): string {
497- return process.env.OPENCODE_POLICY_ENGINE_CONFIG
498- ?? join(homedir(), ".config", "opencode", "policy-engine.json")
499-}
500-
501-export function openCodePolicyPaths(): { cacheFile: string; auditFile: string } {
502- const directory = process.env.OPENCODE_POLICY_ENGINE_DIR
503- ?? join(homedir(), ".config", "opencode", "policy-engine")
504- return {
505- cacheFile: join(directory, "cache", "decisions.jsonl"),
506- auditFile: join(directory, "logs", "policy.jsonl"),
507- }
508-}
509-
510-export function loadOpenCodePolicyConfig(): OpenCodePolicyEngineConfig {
511- const path = configPath()
512- if (!existsSync(path)) return { reviewer: parseReviewerConfig({}) }
513- return { reviewer: parseReviewerConfig(JSON.parse(readFileSync(path, "utf8"))) }
514-}
515diff --git a/src/opencode/credentials.ts b/src/opencode/credentials.ts
516deleted file mode 100644
517index 100e16ac4d8d089163a40d8ea9f981c8f7bb582f..0000000000000000000000000000000000000000
518--- a/src/opencode/credentials.ts
519+++ /dev/null
520@@ -1,67 +0,0 @@
521-import { readFileSync } from "node:fs"
522-
523-type ProviderContextLike = {
524- id?: unknown
525- key?: unknown
526- options?: Record<string, unknown>
527- info?: {
528- id?: unknown
529- key?: unknown
530- options?: Record<string, unknown>
531- }
532-}
533-
534-const sessionKeys = new Map<string, string>()
535-
536-function stringValue(value: unknown): string | undefined {
537- return typeof value === "string" ? value : undefined
538-}
539-
540-function usableKey(value: string | undefined): string | undefined {
541- if (!value || value.startsWith("opencode-")) return undefined
542- return value
543-}
544-
545-function providerKey(provider: ProviderContextLike): string | undefined {
546- return usableKey(stringValue(provider.key))
547- ?? usableKey(stringValue(provider.info?.key))
548- ?? usableKey(stringValue(provider.options?.apiKey))
549- ?? usableKey(stringValue(provider.info?.options?.apiKey))
550-}
551-
552-export function captureOpenCodeCredentials(sessionId: string, provider: unknown): void {
553- if (!provider || typeof provider !== "object") return
554- const context = provider as ProviderContextLike
555- const providerId = context.id ?? context.info?.id
556- if (providerId !== "openai") return
557- const key = providerKey(context)
558- if (key) sessionKeys.set(sessionId, key)
559-}
560-
561-function resolvePlaceholder(value: string): string {
562- const trimmed = value.trim()
563- const environment = trimmed.match(/^\{env:([^}]+)\}$/)
564- if (environment) return process.env[environment[1]]?.trim() ?? ""
565-
566- const file = trimmed.match(/^\{file:([^}]+)\}$/)
567- if (file) {
568- try {
569- return readFileSync(file[1], "utf8").trim()
570- } catch {
571- return ""
572- }
573- }
574- return trimmed
575-}
576-
577-export function resolveOpenCodeOpenAIKey(sessionId?: string): string {
578- const captured = usableKey(resolvePlaceholder(sessionId ? sessionKeys.get(sessionId) ?? "" : ""))
579- if (captured) return captured
580- const environment = usableKey(process.env.OPENAI_API_KEY?.trim())
581- if (environment) return environment
582- throw new Error("No OpenAI API key available")
583-}
584-
585-export function clearOpenCodeCredentials(): void {
586- sessionKeys.clear()
587-}
588diff --git a/src/opencode/index.ts b/src/opencode/index.ts
589deleted file mode 100644
590index 4dfae52fddb4d6e52b9f344f26b4d075abb3009f..0000000000000000000000000000000000000000
591--- a/src/opencode/index.ts
592+++ /dev/null
593@@ -1,54 +0,0 @@
594-import type { Plugin, PluginModule } from "@opencode-ai/plugin"
595-import { createJsonlDecisionAudit } from "../core/audit"
596-import { createJsonlDecisionCache } from "../core/cache"
597-import { checkDeterministic } from "../core/deterministic"
598-import { auditInputSummary, cacheKey, normalizeRequest } from "../core/normalize"
599-import { createPolicyPipeline } from "../core/pipeline"
600-import { createPolicyReviewer } from "../core/review"
601-import { loadOpenCodePolicyConfig, openCodePolicyPaths } from "./config"
602-import { captureOpenCodeCredentials, resolveOpenCodeOpenAIKey } from "./credentials"
603-import { isOpenCodePermissionAsked, openCodePolicyRequests } from "./permission-events"
604-
605-export const PolicyEngine: Plugin = async (ctx) => {
606- const config = loadOpenCodePolicyConfig()
607- const paths = openCodePolicyPaths()
608- const pipeline = createPolicyPipeline({
609- deterministic: (request) => checkDeterministic(request.toolName, request.input),
610- cache: createJsonlDecisionCache(paths.cacheFile),
611- audit: createJsonlDecisionAudit(paths.auditFile),
612- reviewer: createPolicyReviewer(config.reviewer, resolveOpenCodeOpenAIKey),
613- normalize: (request, context) => `${context.cwd ?? ""}\n${normalizeRequest(request.toolName, request.input)}`,
614- cacheKey,
615- inputSummary: auditInputSummary,
616- })
617-
618- return {
619- "chat.params": async (input) => {
620- captureOpenCodeCredentials(input.sessionID, input.provider)
621- },
622- event: async ({ event }) => {
623- const receivedEvent: unknown = event
624- if (!isOpenCodePermissionAsked(receivedEvent)) return
625-
626- const requests = openCodePolicyRequests(receivedEvent)
627- const result = await pipeline.evaluateMany(requests, {
628- sessionId: receivedEvent.properties.sessionID,
629- cwd: ctx.directory,
630- })
631- if (result.decision.decision === "ask") return
632-
633- await ctx.client.postSessionIdPermissionsPermissionId({
634- path: {
635- id: receivedEvent.properties.sessionID,
636- permissionID: receivedEvent.properties.id,
637- },
638- body: { response: result.decision.decision === "allow" ? "once" : "reject" },
639- })
640- },
641- }
642-}
643-
644-export default {
645- id: "policy-engine",
646- server: PolicyEngine,
647-} satisfies PluginModule
648diff --git a/src/opencode/permission-events.ts b/src/opencode/permission-events.ts
649deleted file mode 100644
650index 7a307e85fbbcb179f9f272cf2a092c18a80c8db4..0000000000000000000000000000000000000000
651--- a/src/opencode/permission-events.ts
652+++ /dev/null
653@@ -1,43 +0,0 @@
654-import type { PolicyRequest } from "../core/types"
655-
656-export type OpenCodePermissionAsked = {
657- type: "permission.asked"
658- properties: {
659- id: string
660- sessionID: string
661- permission: string
662- patterns: string[]
663- metadata: Record<string, unknown>
664- always: string[]
665- }
666-}
667-
668-export function isOpenCodePermissionAsked(event: unknown): event is OpenCodePermissionAsked {
669- if (!event || typeof event !== "object") return false
670- const record = event as { type?: unknown; properties?: unknown }
671- if (record.type !== "permission.asked" || !record.properties || typeof record.properties !== "object") return false
672- const properties = record.properties as Record<string, unknown>
673- return typeof properties.id === "string"
674- && typeof properties.sessionID === "string"
675- && typeof properties.permission === "string"
676- && Array.isArray(properties.patterns)
677- && properties.patterns.every((pattern) => typeof pattern === "string")
678-}
679-
680-function inputForPattern(
681- toolName: string,
682- pattern: string,
683- metadata: Record<string, unknown>,
684-): Record<string, unknown> {
685- if (toolName === "bash") return { command: pattern }
686- if (toolName === "webfetch") return { url: pattern }
687- return { ...metadata, pattern }
688-}
689-
690-export function openCodePolicyRequests(event: OpenCodePermissionAsked): PolicyRequest[] {
691- const { permission, patterns, metadata } = event.properties
692- return patterns.map((pattern) => ({
693- toolName: permission,
694- input: inputForPattern(permission, pattern, metadata),
695- }))
696-}
697diff --git a/src/pi/credentials.ts b/src/pi/credentials.ts
698deleted file mode 100644
699index 1089aa0a901115eb1cabf8e87326a9076c311210..0000000000000000000000000000000000000000
700--- a/src/pi/credentials.ts
701+++ /dev/null
702@@ -1,32 +0,0 @@
703-import type { ExtensionContext } from "@earendil-works/pi-coding-agent"
704-
705-const sessionKeys = new Map<string, string>()
706-
707-function apiKey(value: unknown): string | undefined {
708- if (!value || typeof value !== "object") return undefined
709- const record = value as Record<string, unknown>
710- if (typeof record.apiKey === "string") return record.apiKey
711- if (typeof record.key === "string") return record.key
712- return apiKey(record.auth) ?? apiKey(record.credentials)
713-}
714-
715-export async function capturePiCredentials(ctx: ExtensionContext): Promise<void> {
716- try {
717- const auth = await ctx.modelRegistry.getProviderAuth("openai")
718- const key = apiKey(auth)
719- if (key) sessionKeys.set(ctx.sessionManager.getSessionId(), key)
720- } catch {
721- }
722-}
723-
724-export function resolvePiOpenAIKey(sessionId?: string): string {
725- const captured = sessionId ? sessionKeys.get(sessionId)?.trim() : undefined
726- if (captured) return captured
727- const environment = process.env.OPENAI_API_KEY?.trim()
728- if (environment) return environment
729- throw new Error("No OpenAI API key available")
730-}
731-
732-export function clearPiCredentials(): void {
733- sessionKeys.clear()
734-}
735diff --git a/src/pi/index.ts b/src/pi/index.ts
736index 2aae435efbf215447ee4f73263b2d8427494fcba..0e1145053d48ec697657c9eaea14eb774628b151 100644
737--- a/src/pi/index.ts
738+++ b/src/pi/index.ts
739@@ -1,15 +1,15 @@
740-import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
741+import type { UserMessage } from "@earendil-works/pi-ai"
742+import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"
743 import { createJsonlDecisionAudit } from "../core/audit"
744 import { createJsonlDecisionCache } from "../core/cache"
745 import { checkDeterministic } from "../core/deterministic"
746 import { auditInputSummary, cacheKey, normalizeRequest } from "../core/normalize"
747 import { createPolicyPipeline } from "../core/pipeline"
748-import { createPolicyReviewer } from "../core/review"
749+import { createPolicyReviewer, type PiReviewerCaller } from "../core/review"
750 import type { PolicyContext, PolicyEvaluation, PolicyRequest } from "../core/types"
751 import { splitBashCommand, type BashSplitResult } from "./bash-split"
752 import { loadPiPolicyConfig, piPolicyPaths } from "./config"
753 import { PolicyApprovalDialog } from "./policy-approval"
754-import { capturePiCredentials, resolvePiOpenAIKey } from "./credentials"
755 import {
756 createSessionBashAllowOverride,
757 matchesSessionBashAllowOverride,
758@@ -51,9 +51,37 @@ function bypassesPiPolicy(toolName: string): boolean {
759 return toolName === "mcp" || toolName.startsWith("mcp__")
760 }
761
762+function piReviewerCaller(ctx: ExtensionContext): PiReviewerCaller {
763diff --git a/test/core/llm.test.ts b/test/core/llm.test.ts
764index de8857d020bb3adaf47a1ba490ac27dc0b6dc515..b04f9795e133d00c7b524f4e73545bcc69578610 100644
765--- a/test/core/llm.test.ts
766+++ b/test/core/llm.test.ts
767@@ -19,8 +19,8 @@ const reviewer =
768 model,
769 enableThinking: true,
770 reasoningFormat: "deepseek",
771- }, () => {
772- throw new Error("local llama.cpp reviewer does not use an API key");
773+ }, async () => {
774+ throw new Error("Pi reviewer must not be used for llama.cpp evaluation");
775 })
776 : undefined;
777
778diff --git a/test/opencode/extension.test.ts b/test/opencode/extension.test.ts
779deleted file mode 100644
780index b2bf2fa4d72fab45d17914e0f98ec6fe47762157..0000000000000000000000000000000000000000
781--- a/test/opencode/extension.test.ts
782+++ /dev/null
783@@ -1,60 +0,0 @@
784-import { afterEach, beforeEach, describe, expect, test } from "bun:test"
785-import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
786-import { tmpdir } from "node:os"
787-import { join } from "node:path"
788-import { PolicyEngine } from "../../src/opencode/index"
789-
790-const originalConfig = process.env.OPENCODE_POLICY_ENGINE_CONFIG
791-const originalDirectory = process.env.OPENCODE_POLICY_ENGINE_DIR
792-const originalKey = process.env.OPENAI_API_KEY
793-const originalFetch = globalThis.fetch
794-let directory: string
795-
796-beforeEach(() => {
797- directory = mkdtempSync(join(tmpdir(), "opencode-policy-engine-"))
798- process.env.OPENCODE_POLICY_ENGINE_CONFIG = join(directory, "config.json")
799- process.env.OPENCODE_POLICY_ENGINE_DIR = directory
800- process.env.OPENAI_API_KEY = "test-key"
801- writeFileSync(process.env.OPENCODE_POLICY_ENGINE_CONFIG, "{}")
802-})
803-
804-afterEach(() => {
805- globalThis.fetch = originalFetch
806- rmSync(directory, { recursive: true, force: true })
807- if (originalConfig === undefined) delete process.env.OPENCODE_POLICY_ENGINE_CONFIG
808- else process.env.OPENCODE_POLICY_ENGINE_CONFIG = originalConfig
809- if (originalDirectory === undefined) delete process.env.OPENCODE_POLICY_ENGINE_DIR
810- else process.env.OPENCODE_POLICY_ENGINE_DIR = originalDirectory
811- if (originalKey === undefined) delete process.env.OPENAI_API_KEY
812- else process.env.OPENAI_API_KEY = originalKey
813-})
814-
815-function asked(patterns: string[], id: string) {
816- return {
817- type: "permission.asked",
818- properties: { id, sessionID: "session-1", permission: "bash", patterns, metadata: {}, always: [] },
819- }
820-}
821-
822-describe("OpenCode policy plugin", () => {
823- test("routes allow, ask, deny, and unrelated events through the host API", async () => {
824- const replies: unknown[] = []
825- globalThis.fetch = (async () => Response.json({
826- choices: [{ message: { content: '{"decision":"deny","reason":"blocked","category":"dangerous"}' } }],
827- })) as unknown as typeof fetch
828- const plugin = await PolicyEngine({
829- directory,
830- client: { postSessionIdPermissionsPermissionId: async (input: unknown) => { replies.push(input) } },
831- } as never)
832-
833- await plugin.event?.({ event: asked(["git status"], "allow") } as never)
834- await plugin.event?.({ event: asked(["git status", "sudo id"], "ask") } as never)
835- await plugin.event?.({ event: asked(["unknown-command"], "deny") } as never)
836- await plugin.event?.({ event: { type: "session.idle", properties: { sessionID: "session-1" } } } as never)
837-
838- expect(replies).toEqual([
839- { path: { id: "session-1", permissionID: "allow" }, body: { response: "once" } },
840- { path: { id: "session-1", permissionID: "deny" }, body: { response: "reject" } },
841- ])
842- })
843-})
844diff --git a/test/pi/extension.test.ts b/test/pi/extension.test.ts
845index 6888f46910823eb663bccd20b051508380ff10e5..f791a24b825a843fc82ef8cc76caa0f9c1f04bfb 100644
846--- a/test/pi/extension.test.ts
847+++ b/test/pi/extension.test.ts
848@@ -64,7 +64,17 @@ function context(
849 return {
850 hasUI,
851 model: undefined,
852- modelRegistry: {},
853+ modelRegistry: {
854+ find(provider: string, id: string) {
855+ return { provider, id }
856+ },
857+ async complete() {
858+ return {
859+ stopReason: "stop",
860+ content: [{ type: "text", text: '{"decision":"ask","reason":"reviewed","category":"uncertain"}' }],
861+ }
862+ },
863+ },
864 cwd,
865 sessionManager: { getSessionId: () => "test-session", getBranch: () => [] },
866 ui: { confirm, notify() {} },
867@@ -110,13 +120,6 @@ describe("Pi policy extension", () => {
868 externalDirectories: [externalDirectory],
869 }))
870
871- let reviews = 0
872- let prompt = ""
873- globalThis.fetch = (async (_input, init) => {
874- reviews += 1
875- prompt = JSON.parse(String(init?.body)).messages[0].content
876- return Response.json({ choices: [{ message: { content: '{"decision":"ask","reason":"reviewed","category":"uncertain"}' } }] })
877- }) as typeof fetch
878
879 const extension = loadExtension()
880 await expect(extension.toolCall({ toolName: "custom_tool", input: {} }, context())).resolves.toBeUndefined()
881@@ -127,7 +130,24 @@ describe("Pi policy extension", () => {
882 await expect(extension.toolCall({ toolName: "mcp", input: {} }, context())).resolves.toBeUndefined()
883 await expect(extension.toolCall({ toolName: "read", input: { path: join(externalDirectory, "file") } }, context())).resolves.toBeUndefined()
884
885- const confirmed = context(true, async () => true)
886+ let reviews = 0
887+ let prompt = ""
888+ const confirmed = {
889+ ...context(true, async () => true),
890+ modelRegistry: {
891+ find(provider: string, id: string) {
892+ return { provider, id }
893+ },
894+ async complete(_model: unknown, request: { systemPrompt?: string }) {
895+ reviews += 1
896+ prompt = request.systemPrompt ?? ""
897+ return {
898+ stopReason: "stop",
899+ content: [{ type: "text", text: '{"decision":"ask","reason":"reviewed","category":"uncertain"}' }],
900+ }
901+ },
902+ },
903+ }
904 const externalFile = join(tmpdir(), `not-listed-${Date.now()}`, "file")
905 await expect(extension.toolCall({ toolName: "read", input: { path: externalFile } }, confirmed)).resolves.toBeUndefined()
906 await expect(extension.toolCall({ toolName: "read", input: { path: externalFile } }, confirmed)).resolves.toBeUndefined()
907@@ -143,24 +163,93 @@ describe("Pi policy extension", () => {
908 })
909
910 test("shows an unparseable LLM response in the approval prompt", async () => {
911- globalThis.fetch = (async (_input, _init) => {
912- return Response.json({ choices: [{ message: { content: "not JSON" } }] })
913- }) as typeof fetch
914 let message = ""
915
916 const extension = loadExtension()
917 await extension.toolCall(
918 { toolName: "unknown_tool", input: {} },
919- context(true, async (_title: string, value: string) => {
920- message = value
921- return false
922- }),
923+ {
924+ ...context(true, async (_title: string, value: string) => {
925+ message = value
926+ return false
927+ }),
928+ modelRegistry: {
929+ find(provider: string, id: string) {
930+ return { provider, id }
931+ },
932+ async complete() {
933+ return { stopReason: "stop", content: [{ type: "text", text: "not JSON" }] }
934+ },
935+ },
936+ },
937 )
938
939 expect(message).toContain("Unparseable LLM response")
940 expect(message).toContain("LLM response:\nnot JSON")
941 })
942
943+ test("uses Pi model completion for Pi reviewers", async () => {
944+ writeFileSync(join(tempDir, "policy-engine.json"), JSON.stringify({
945+ reviewer: {
946+ kind: "pi",
947+ provider: "openai-codex",