5b5777192a4065b39b88cb6fc348fc99d737e349
- 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 0831b5a8f8f035e0cbd5a5ae4dfa120afcd957b4..f924103851d0791d1ea5945ca711f4d3ae7d3415 100644
3--- a/AGENTS.md
4+++ b/AGENTS.md
5@@ -1,38 +1,45 @@
6-# Policy Engine - Agent Instructions
7+# Policy Engine
8
9-## Overview
10+## Purpose
11
12-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.
13+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`.
14
15-## Project Structure
16+## Layout
17
18-- `src/index.ts` — Pi extension entry point, `tool_call` handler, and pipeline orchestration
19-- `src/rules.ts` — Regex patterns, `SHELL_CONTROL_RE`, `STDERR_REDIRECT_RE`, and the LLM policy prompt
20-- `src/deterministic.ts` — Pattern matching logic against the rules
21-- `src/llm.ts` — LLM policy evaluation, response parsing, and error fallback
22-- `src/api.ts` — OpenAI and OpenAI-compatible llama-server clients
23-- `src/config.ts` — LLM backend configuration loading
24-- `src/normalizer.ts` — Request normalization and cache-key generation
25-- `src/cache.ts` — JSONL decision cache
26-- `src/logger.ts` — JSONL decision logging
27-- `src/types.ts` — Shared decision types
28+- `src/core/` — shared rules, deterministic checks, pipeline, reviewer, providers, cache, audit, normalization, and configuration.
29+- `src/pi/` — Pi extension, static permissions, Bash parsing, credentials, UI, and session Bash overrides.
30+- `src/opencode/` — OpenCode plugin, configuration, credentials, and permission event conversion.
31+- `test/core/`, `test/pi/`, `test/opencode/` — unit and extension tests.
32
33-## Commands
34+The package root exports the OpenCode plugin. `./pi` exports the Pi extension. `src/index.ts` re-exports `./pi/index` for Pi extension loading.
35+
36+## Policy flow
37+
38+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.
39+
40+OpenCode converts each `permission.asked` pattern to a policy request. It has no Pi prechecks.
41
42-- **Tests:** `bun test`
43-- **Lint:** `bunx eslint src/`
44-- **Typecheck:** `bunx tsc --noEmit`
45diff --git a/README.md b/README.md
46index 2b8079b7fc3778313754ab15c70690d922f62a12..3c992544adeacaca6b25bf7fbda1c1c5a7389775 100644
47--- a/README.md
48+++ b/README.md
49@@ -95,7 +95,7 @@ Pi uses root-level `tools` and `externalDirectories` maps. Any tool name can be
50
51 ## Pi behavior
52
53-Pi keeps `/allow-bash`, Pi static permissions, and Herdr `herdr:blocked` events around its confirmation dialog. Pi bypasses MCP tools at the Pi adapter boundary.
54diff --git a/src/core/pipeline.ts b/src/core/pipeline.ts
55index d92d2b91d066b44cd3736e45e193c845a17717a2..f7e37f8942b48b9b946f21d2a1f9a3353fdb6480 100644
56--- a/src/core/pipeline.ts
57+++ b/src/core/pipeline.ts
58@@ -24,6 +24,7 @@ export type PolicyPipelineOptions = {
59 type EvaluationOptions = {
60 skipPrechecks?: boolean
61 skipDeterministic?: boolean
62+ skipLLMReview?: boolean
63 }
64
65 const EMPTY_DECISION: Decision = {
66@@ -98,6 +99,17 @@ export function createPolicyPipeline(options: PolicyPipelineOptions) {
67 } catch {
68 }
69
70+ if (evaluationOptions.skipLLMReview) {
71+ return complete(request, context, {
72+ decision: {
73+ decision: "allow",
74+ reason: "LLM permission checks are disabled for this session",
75+ category: "session_allow",
76+ },
77+ source: "session",
78+ }, startedAt)
79+ }
80+
81 const reviewed = await options.reviewer.evaluate(request, context)
82 const result: PolicyEvaluation = {
83 decision: reviewed.decision,
84diff --git a/src/core/rules.ts b/src/core/rules.ts
85index 050896f9812f5ec5903a6dcb60b14ca622ab74d7..eba89725567dd3a808b70bf02492067724c7099b 100644
86--- a/src/core/rules.ts
87+++ b/src/core/rules.ts
88@@ -1,7 +1,7 @@
89 import { DECISION_CATEGORIES } from "./types";
90
91 // Bump to invalidate all cached decisions when rules change.
92-export const POLICY_VERSION = 30;
93+export const POLICY_VERSION = 31;
94
95 // Trailing stderr redirections that are safe to strip before pattern matching.
96 // `2>&1` and `2>/dev/null` have no security implication but would otherwise
97@@ -54,6 +54,8 @@ export const HARD_ALLOW_PATTERNS: RegExp[] = [
98 /^cat\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*(?:\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*)*\s*$/,
99 // base64 with relative paths only
100 /^base64\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*(?:\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*)*\s*$/,
101+ // Bare base64 reads from an already-evaluated pipe.
102+ /^base64(?:\s+-d)?\s*$/,
103 // head/tail with relative paths and line limits
104 /^(head|tail)(?:\s+-(?:n\s*)?\d+)?(?:\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*)*\s*$/,
105 // find read-only on relative paths
106diff --git a/src/pi/index.ts b/src/pi/index.ts
107index ee1d4a13ddb416857075379119a41da4e69cce4d..57abbe6c8566859a0f85da3aca762aa76132ea0b 100644
108--- a/src/pi/index.ts
109+++ b/src/pi/index.ts
110@@ -81,15 +81,17 @@ export async function evaluatePiToolCall(
111 sessionBashAllowOverride: SessionBashAllowOverride | undefined,
112 captureCredentials: () => Promise<void>,
113 splitBash: (command: string) => Promise<BashSplitResult> = splitBashCommand,
114+ skipPermissions = false,
115 ): Promise<PolicyEvaluation> {
116 const pipeline = createPiPipeline(sessionBashAllowOverride, context.cwd ?? process.cwd())
117+ const evaluationOptions = { skipPrechecks: true, skipLLMReview: skipPermissions }
118 const request: PolicyRequest = { toolName, input }
119 const prechecked = await pipeline.precheck(request, context)
120 if (prechecked) return prechecked
121
122- await captureCredentials()
123+ if (!skipPermissions) await captureCredentials()
124 if (toolName !== "bash" || typeof input.command !== "string") {
125- return pipeline.evaluate(request, context, { skipPrechecks: true })
126+ return pipeline.evaluate(request, context, evaluationOptions)
127 }
128
129 const split = await splitBash(input.command)
130@@ -98,13 +100,14 @@ export async function evaluatePiToolCall(
131 input: { ...input, command },
132 }))
133 return pipeline.evaluateMany(requests, context, {
134- skipPrechecks: true,
135+ ...evaluationOptions,
136 skipDeterministic: !split.parsed,
137 })
138 }
139
140 export default function policyEngine(pi: ExtensionAPI) {
141 let sessionBashAllowOverride: SessionBashAllowOverride | undefined
142+ let skipPermissions = false
143
144 pi.on("session_start", (_event, ctx) => {
145 sessionBashAllowOverride = restoreSessionBashAllowOverride(
146@@ -151,6 +154,19 @@ export default function policyEngine(pi: ExtensionAPI) {
147 },
148 })
149
150+ pi.registerCommand("skip-permissions", {
151+ description: "Temporarily skip LLM permission checks",
152+ handler: async (args, ctx) => {
153+ if (args.trim() === "clear") {
154+ skipPermissions = false
155+ ctx.ui.notify("LLM permission checks enabled.", "info")
156+ return
157+ }
158+ skipPermissions = true
159+ ctx.ui.notify("LLM permission checks disabled until this extension reloads. Use /skip-permissions clear to re-enable them.", "warning")
160+ },
161+ })
162+
163 pi.on("tool_call", async (event, ctx) => {
164 if (bypassesPiPolicy(event.toolName)) return undefined
165
166@@ -165,6 +181,8 @@ export default function policyEngine(pi: ExtensionAPI) {
167 },
168 sessionBashAllowOverride,
169 () => capturePiCredentials(ctx),
170+ splitBashCommand,
171+ skipPermissions,
172 )
173
174 if (result.decision.decision === "allow") return undefined
175diff --git a/test/pi/extension.test.ts b/test/pi/extension.test.ts
176index ab482509567c6a3c5d1ddf903b47520d87dc37dd..acbd364ff2a3ad9cf06f30a57156a99870400096 100644
177--- a/test/pi/extension.test.ts
178+++ b/test/pi/extension.test.ts
179@@ -33,6 +33,7 @@ function loadExtension() {
180 let toolCall: ToolCallHandler | undefined
181 let sessionStart: SessionStartHandler | undefined
182 let allowBash: CommandHandler | undefined
183+ let skipPermissions: CommandHandler | undefined
184 const entries: SessionEntry[] = []
185 const events: { name: string; data: unknown }[] = []
186 PolicyEngine({
187@@ -42,14 +43,15 @@ function loadExtension() {
188 },
189 registerCommand(name: string, command: { handler: CommandHandler }) {
190 if (name === "allow-bash") allowBash = command.handler
191+ if (name === "skip-permissions") skipPermissions = command.handler
192 },
193 appendEntry(customType: string, data: unknown) {
194 entries.push({ customType, data })
195 },
196 events: { emit(name: string, data: unknown) { events.push({ name, data }) } },
197 } as any)
198- if (!toolCall || !sessionStart || !allowBash) throw new Error("policy handlers were not registered")
199- return { toolCall, sessionStart, allowBash, entries, events }
200+ if (!toolCall || !sessionStart || !allowBash || !skipPermissions) throw new Error("policy handlers were not registered")
201+ return { toolCall, sessionStart, allowBash, skipPermissions, entries, events }
202 }
203
204 function context(hasUI = false, confirm = async () => false) {
205@@ -104,6 +106,39 @@ describe("Pi policy extension", () => {
206 ])
207 })
208
209+ test("allows base64 from a pipe", async () => {
210+ let reviews = 0
211+ globalThis.fetch = (async (_input, _init) => {
212+ reviews += 1
213+ return Response.error()
214+ }) as typeof fetch
215+
216+ const extension = loadExtension()
217+ await expect(extension.toolCall({ toolName: "bash", input: { command: "printf test | base64" } }, context())).resolves.toBeUndefined()
218+ await expect(extension.toolCall({ toolName: "bash", input: { command: "printf dGVzdA== | base64 -d" } }, context())).resolves.toBeUndefined()
219+ expect(reviews).toBe(0)
220+ })
221+
222+ test("skips LLM checks but keeps static and deterministic checks until reload", async () => {
223+ writeFileSync(process.env.PI_POLICY_ENGINE_CONFIG!, JSON.stringify({ tools: { blocked_tool: "deny" } }))
224+ let reviews = 0
225+ globalThis.fetch = (async (_input, _init) => {
226+ reviews += 1
227+ return Response.error()
228+ }) as typeof fetch
229+
230+ const extension = loadExtension()
231+ await extension.skipPermissions("", context())
232+ await expect(extension.toolCall({ toolName: "blocked_tool", input: {} }, context())).resolves.toMatchObject({ block: true })
233+ await expect(extension.toolCall({ toolName: "unknown_tool", input: {} }, context())).resolves.toBeUndefined()
234+ await expect(extension.toolCall({ toolName: "bash", input: { command: "sudo id" } }, context())).resolves.toMatchObject({ block: true })
235+ expect(reviews).toBe(0)
236+
237+ const reloaded = loadExtension()
238+ await expect(reloaded.toolCall({ toolName: "unknown_tool", input: {} }, context())).resolves.toMatchObject({ block: true })
239+ expect(reviews).toBe(1)
240+ })
241+
242 test("restores session Bash overrides on reload but not forks", async () => {
243 writeFileSync(process.env.PI_POLICY_ENGINE_CONFIG!, JSON.stringify({ tools: { bash: "deny" } }))
244 const original = loadExtension()