5b5777192a4065b39b88cb6fc348fc99d737e349

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

Message

add skip permissions command

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()