c737df3350849cafa41368138248f017159fc25a

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

Message

Partially port permission system from opencode

Diff

This diff is truncated to protect this page.

  1diff --git a/README.md b/README.md
  2index 1fdbc7e51f0d54356829d7a2173f7682c4f0a02e..bee627fd1a1be73b1303f2ac073290f929671a35 100644
  3--- a/README.md
  4+++ b/README.md
  5@@ -62,7 +62,7 @@ The extension handles Pi's `tool_call` event and evaluates each tool call before
  6 
  7 **Stage 3 — LLM** sends the tool input to the configured model with a security-focused prompt. Successful responses are cached; errors and unparseable responses become `ask` decisions and are not cached.
  8 
  9-`webfetch` is deterministically allowed as read-only. Other tool types, including MCP tools, proceed to the cache and LLM stages. Shell syntax prevents deterministic allow matches, so compound Bash commands normally proceed to the LLM stage.
 10diff --git a/src/config.ts b/src/config.ts
 11index d483c7994e6ddbf8e4fe755d4b0e87014a757268..ffa817d4842d19a7cd43e04f034b5ae52319b313 100644
 12--- a/src/config.ts
 13+++ b/src/config.ts
 14@@ -2,12 +2,23 @@ import { existsSync, readFileSync } from "node:fs"
 15 import { homedir } from "node:os"
 16 import { join } from "node:path"
 17 
 18+export type PermissionAction = "allow" | "check" | "deny"
 19+export type PermissionRules = PermissionAction | Record<string, PermissionAction>
 20+
 21+export type PermissionConfig = {
 22+  bash?: PermissionRules
 23+  edit?: PermissionRules
 24+  external_directory?: PermissionRules
 25+  webfetch?: PermissionRules
 26+}
 27+
 28 export type PolicyEngineConfig = {
 29   modelBackend: "openai" | "local"
 30   llamaServer: {
 31     baseUrl: string
 32     model: string
 33   }
 34+  permission?: PermissionConfig
 35 }
 36 
 37 const DEFAULT_CONFIG: PolicyEngineConfig = {
 38@@ -33,6 +44,30 @@ function stringValue(value: unknown): string | undefined {
 39   return typeof value === "string" && value.trim() ? value.trim() : undefined
 40 }
 41 
 42+function permissionAction(value: unknown, field: string): PermissionAction {
 43+  if (value === "allow" || value === "check" || value === "deny") return value
 44+  throw new Error(`${field} must be "allow", "check", or "deny"`)
 45+}
 46+
 47+function permissionRules(value: unknown, field: string): PermissionRules {
 48+  if (typeof value === "string") return permissionAction(value, field)
 49+  const data = objectValue(value)
 50+  if (!data) throw new Error(`${field} must be a permission action or an object`)
 51+
 52+  return Object.fromEntries(
 53+    Object.entries(data).map(([pattern, action]) => [pattern, permissionAction(action, `${field}.${pattern}`)]),
 54+  )
 55+}
 56+
 57+function permissionConfig(value: unknown, path: string): PermissionConfig {
 58+  const data = objectValue(value)
 59+  if (!data) throw new Error(`${path} must be a JSON object`)
 60+
 61+  return Object.fromEntries(
 62+    Object.entries(data).map(([name, rules]) => [name, permissionRules(rules, `${path}.${name}`)]),
 63+  ) as PermissionConfig
 64+}
 65+
 66 export function loadConfig(): PolicyEngineConfig {
 67   const path = configPath()
 68   if (!existsSync(path)) return DEFAULT_CONFIG
 69@@ -48,9 +83,11 @@ export function loadConfig(): PolicyEngineConfig {
 70   const llamaServer = objectValue(data.llamaServer) ?? {}
 71   const baseUrl = stringValue(llamaServer.baseUrl) ?? DEFAULT_CONFIG.llamaServer.baseUrl
 72   const model = stringValue(llamaServer.model) ?? DEFAULT_CONFIG.llamaServer.model
 73+  const permission = data.permission === undefined ? undefined : permissionConfig(data.permission, `${path}.permission`)
 74 
 75   return {
 76     modelBackend,
 77     llamaServer: { baseUrl, model },
 78+    permission,
 79   }
 80 }
 81diff --git a/src/deterministic.ts b/src/deterministic.ts
 82index c5f3d2d485fe0d657df4fab77323339e4f0bdacc..f6b16f240b141cde47f3c9416f3ed23e21f8d7a6 100644
 83--- a/src/deterministic.ts
 84+++ b/src/deterministic.ts
 85@@ -80,7 +80,13 @@ export function checkDeterministic(
 86     case "webfetch":
 87       return {
 88         decision: "allow",
 89-        reason: `WebFetch is read-only`,
 90+        reason: "WebFetch is read-only",
 91+        category: "web_read",
 92+      }
 93+    case "websearch":
 94+      return {
 95+        decision: "allow",
 96+        reason: "WebSearch is read-only",
 97         category: "web_read",
 98       }
 99     default:
100diff --git a/src/index.ts b/src/index.ts
101index 75ca445586c3dabb0ca0346e510d09fa2942cfd9..aeb09d709dde1023bab23c6b2be1eccf40d1b9d9 100644
102--- a/src/index.ts
103+++ b/src/index.ts
104@@ -4,6 +4,7 @@ import { captureProviderCredentials } from "./api"
105 import { lookupCache, writeCache } from "./cache"
106 import { checkDeterministic } from "./deterministic"
107 import { evaluateWithLLM } from "./llm"
108+import { checkStaticPermission } from "./static-permissions"
109 import { logDecision } from "./logger"
110 import { cacheKey, normalizeRequest } from "./normalizer"
111 import { notify } from "./notify"
112@@ -46,9 +47,11 @@ async function runPipelineSingle(
113   toolType: string,
114   input: ToolInput,
115   sessionId: string,
116+  cwd?: string,
117 ): Promise<PipelineResult> {
118   const start = performance.now()
119-  const deterministic = checkDeterministic(toolType, input)
120+  const staticPermission = cwd ? await checkStaticPermission(toolType, input, cwd) : undefined
121+  const deterministic = staticPermission ?? checkDeterministic(toolType, input)
122   const normalized = normalizeRequest(toolType, input)
123 
124   if (deterministic) {
125@@ -56,11 +59,11 @@ async function runPipelineSingle(
126       toolType,
127       normalized,
128       decision: deterministic,
129-      source: "deterministic",
130+      source: staticPermission ? "static" : "deterministic",
131       timingMs: performance.now() - start,
132       sessionId,
133     })
134-    return { decision: deterministic, source: "deterministic" }
135+    return { decision: deterministic, source: staticPermission ? "static" : "deterministic" }
136   }
137 
138   const key = cacheKey(normalized)
139@@ -125,7 +128,7 @@ export default function (pi: ExtensionAPI) {
140     await capturePiCredentials(ctx)
141 
142     const input = toolInput(event.input)
143-    const result = await runPipelineSingle(event.toolName, input, ctx.sessionManager.getSessionId())
144+    const result = await runPipelineSingle(event.toolName, input, ctx.sessionManager.getSessionId(), ctx.cwd)
145     const { decision } = result
146 
147     if (decision.decision === "allow") return undefined
148diff --git a/src/rules.ts b/src/rules.ts
149index 0565d525c0ea1b4d76d153602440f411137ed1df..4c1753235d47e3b8a9bad3c834ca8d21c1439c1b 100644
150--- a/src/rules.ts
151+++ b/src/rules.ts
152@@ -1,5 +1,5 @@
153 // Bump to invalidate all cached decisions when rules change.
154-export const POLICY_VERSION = 17;
155+export const POLICY_VERSION = 21;
156 
157 // Trailing stderr redirections that are safe to strip before pattern matching.
158 // `2>&1` and `2>/dev/null` have no security implication but would otherwise
159diff --git a/src/static-permissions.ts b/src/static-permissions.ts
160new file mode 100644
161index 0000000000000000000000000000000000000000..18a19007c7d37864aa252b80d20b0e8eccdd4431
162--- /dev/null
163+++ b/src/static-permissions.ts
164@@ -0,0 +1,124 @@
165+import { realpath } from "node:fs/promises"
166+import { homedir } from "node:os"
167+import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
168+import { loadConfig, type PermissionAction, type PermissionConfig, type PermissionRules } from "./config"
169+import type { Decision } from "./types"
170+
171+const PATH_TOOLS = new Set(["read", "write", "edit", "find", "grep", "ls"])
172+
173+type ToolInput = Record<string, unknown>
174+
175+function patternRegex(pattern: string): RegExp {
176+  const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, "\\$&")
177+  return new RegExp(`^${escaped.replaceAll("*", ".*").replaceAll("?", ".")}$`)
178+}
179+
180+function expandedPattern(pattern: string): string {
181+  if (pattern === "~") return homedir()
182+  if (pattern.startsWith("~/")) return join(homedir(), pattern.slice(2))
183+  if (pattern === "$HOME") return homedir()
184+  if (pattern.startsWith("$HOME/")) return join(homedir(), pattern.slice(6))
185+  return pattern
186+}
187+
188+export function actionFor(rules: PermissionRules | undefined, value: string): PermissionAction | undefined {
189+  if (typeof rules === "string") return rules
190+  if (!rules) return undefined
191+
192+  let action: PermissionAction | undefined
193+  for (const [pattern, rule] of Object.entries(rules)) {
194+    if (patternRegex(expandedPattern(pattern)).test(value)) action = rule
195+  }
196+  return action
197+}
198+
199+async function canonicalPath(path: string, cwd: string): Promise<string> {
200+  const absolutePath = resolve(cwd, path.startsWith("@") ? path.slice(1) : path)
201+  const suffix: string[] = []
202+  let existingPath = absolutePath
203+
204+  while (true) {
205+    try {
206+      return join(await realpath(existingPath), ...suffix)
207+    } catch {
208+      const parentPath = dirname(existingPath)
209+      if (parentPath === existingPath) return absolutePath
210+      suffix.unshift(existingPath.slice(parentPath.length + 1))
211+      existingPath = parentPath
212+    }
213+  }
214+}
215+
216+function isInside(path: string, directory: string): boolean {
217+  const pathFromDirectory = relative(directory, path)
218+  return pathFromDirectory === "" || (!pathFromDirectory.startsWith(`..${sep}`) && pathFromDirectory !== ".." && !isAbsolute(pathFromDirectory))
219+}
220+
221+function staticDecision(
222+  action: PermissionAction | undefined,
223+  reason: string,
224+  category: string,
225+): Decision | undefined {
226+  if (!action || action === "check") return undefined
227+  return { decision: action, reason, category }
228+}
229+
230+function toolRules(toolType: string, config: PermissionConfig): PermissionRules | undefined {
231+  switch (toolType) {
232+    case "bash":
233+      return config.bash
234+    case "edit":
235+    case "write":
236+      return config.edit
237+    case "webfetch":
238+      return config.webfetch
239+    default:
240+      return undefined
241+  }
242+}
243+
244+function toolValue(toolType: string, input: ToolInput): string | undefined {
245+  switch (toolType) {
246+    case "bash":
247+      return typeof input.command === "string" ? input.command : undefined
248+    case "webfetch":
249+      return typeof input.url === "string" ? input.url : undefined
250+    default:
251+      return undefined
252+  }
253+}
254+
255+export async function checkStaticPermission(
256+  toolType: string,
257+  input: ToolInput,
258+  cwd: string,
259+): Promise<Decision | undefined> {
260+  const permission = loadConfig().permission
261+  if (!permission) return undefined
262+
263+  if (PATH_TOOLS.has(toolType)) {
264diff --git a/test/deterministic.test.ts b/test/deterministic.test.ts
265index 35be721a2b559611447b15bdc93e097defa57322..dcb3cdcfcf6143727cea32bf3b0dbc1cae85655a 100644
266--- a/test/deterministic.test.ts
267+++ b/test/deterministic.test.ts
268@@ -204,6 +204,12 @@ test("webfetch always allowed", () => {
269   expect(d!.decision).toBe("allow")
270 })
271 
272+test("websearch always allowed", () => {
273+  const d = checkDeterministic("websearch", { query: "pi" })
274+  expect(d).toBeDefined()
275+  expect(d!.decision).toBe("allow")
276+})
277+
278 test("unknown tool type returns undefined", () => {
279   expect(checkDeterministic("mcp__linear__list", { foo: 1 })).toBeUndefined()
280 })
281diff --git a/test/extension.test.ts b/test/extension.test.ts
282index 6af2a120578878980d15f34ef34a8b805dc36892..b2b22d9c707e5d60e11a6260cb92c23ea4bd7ef2 100644
283--- a/test/extension.test.ts
284+++ b/test/extension.test.ts
285@@ -1,6 +1,24 @@
286-import { describe, expect, test } from "bun:test"
287+import { afterEach, beforeEach, describe, expect, test } from "bun:test"
288+import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
289+import { tmpdir } from "node:os"
290+import { join } from "node:path"
291 import PolicyEngine from "../src/index"
292 
293+const originalConfigPath = process.env.PI_POLICY_ENGINE_CONFIG
294+let tempDir: string
295+
296+beforeEach(() => {
297+  tempDir = mkdtempSync(join(tmpdir(), "policy-engine-"))
298+  process.env.PI_POLICY_ENGINE_CONFIG = join(tempDir, "config.json")
299+  writeFileSync(process.env.PI_POLICY_ENGINE_CONFIG, "{}")
300+})
301+
302+afterEach(() => {
303+  rmSync(tempDir, { recursive: true, force: true })
304+  if (originalConfigPath === undefined) delete process.env.PI_POLICY_ENGINE_CONFIG
305+  else process.env.PI_POLICY_ENGINE_CONFIG = originalConfigPath
306+})
307+
308 type ToolCallHandler = (event: { toolName: string; input: Record<string, unknown> }, ctx: any) => Promise<unknown>
309 
310 function loadToolCallHandler(): ToolCallHandler {
311@@ -31,6 +49,15 @@ describe("Pi policy extension", () => {
312       .resolves.toBeUndefined()
313   })
314 
315+  test("runs check actions through deterministic rules", async () => {
316+    writeFileSync(process.env.PI_POLICY_ENGINE_CONFIG!, JSON.stringify({
317+      permission: { bash: { "*": "check" } },
318+    }))
319+    const handler = loadToolCallHandler()
320+    await expect(handler({ toolName: "bash", input: { command: "git status" } }, context(false)))
321+      .resolves.toBeUndefined()
322+  })
323+
324   test("blocks approval-required commands without a UI", async () => {
325     const handler = loadToolCallHandler()
326     await expect(handler({ toolName: "bash", input: { command: "sudo apt install foo" } }, context(false)))
327diff --git a/test/static-permissions.test.ts b/test/static-permissions.test.ts
328new file mode 100644
329index 0000000000000000000000000000000000000000..9f856baf81a0dbcde261700a3d464e5f1c9e8624
330--- /dev/null
331+++ b/test/static-permissions.test.ts
332@@ -0,0 +1,67 @@
333+import { afterEach, beforeEach, describe, expect, test } from "bun:test"
334+import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
335+import { tmpdir } from "node:os"
336+import { join } from "node:path"
337+import { actionFor, checkStaticPermission } from "../src/static-permissions"
338+
339+const originalConfigPath = process.env.PI_POLICY_ENGINE_CONFIG
340+let tempDir: string
341+let workspace: string
342+let externalDirectory: string
343+
344+beforeEach(() => {
345+  tempDir = mkdtempSync(join(tmpdir(), "policy-engine-"))
346+  workspace = join(tempDir, "workspace")
347+  externalDirectory = join(tempDir, "external")
348+  mkdirSync(workspace)
349+  mkdirSync(externalDirectory)
350+  process.env.PI_POLICY_ENGINE_CONFIG = join(tempDir, "config.json")
351+  writeFileSync(process.env.PI_POLICY_ENGINE_CONFIG, JSON.stringify({
352+    permission: {
353+      edit: "allow",
354+      bash: { "*sudo*": "deny", "*terraform apply*": "deny" },
355+      external_directory: { [`${externalDirectory}/*`]: "allow" },
356+      webfetch: "check",
357+    },
358+  }))
359+})
360+
361+afterEach(() => {
362+  rmSync(tempDir, { recursive: true, force: true })
363+  if (originalConfigPath === undefined) delete process.env.PI_POLICY_ENGINE_CONFIG
364+  else process.env.PI_POLICY_ENGINE_CONFIG = originalConfigPath
365+})
366+
367+describe("static permissions", () => {
368+  test("uses the last matching rule", () => {
369+    expect(actionFor({ "*": "check", "*sudo*": "deny" }, "sudo id")).toBe("deny")
370+  })
371+
372+  test("defaults to check when no static rule matches", async () => {
373+    await expect(checkStaticPermission("bash", { command: "git status" }, workspace))
374+      .resolves.toBeUndefined()
375+    await expect(checkStaticPermission("bash", { command: "sudo id" }, workspace))
376+      .resolves.toMatchObject({ decision: "deny" })
377+    await expect(checkStaticPermission("bash", { command: "terraform apply" }, workspace))
378+      .resolves.toMatchObject({ decision: "deny" })
379+  })
380+
381+  test("allows edits in the workspace and actions in allowed external directories", async () => {
382+    await expect(checkStaticPermission("edit", { path: "file.ts" }, workspace))
383+      .resolves.toMatchObject({ decision: "allow" })
384+    await expect(checkStaticPermission("write", { path: join(externalDirectory, "file.ts") }, workspace))
385+      .resolves.toMatchObject({ decision: "allow" })
386+    await expect(checkStaticPermission("read", { path: join(externalDirectory, "file.ts") }, workspace))
387+      .resolves.toMatchObject({ decision: "allow" })
388+  })
389+
390+  test("passes unlisted external paths to the policy pipeline", async () => {
391+    await expect(checkStaticPermission("read", { path: join(tempDir, "unlisted.txt") }, workspace))
392+      .resolves.toBeUndefined()
393+  })
394+
395+  test("passes webfetch checks to the policy pipeline", async () => {
396+    await expect(checkStaticPermission("webfetch", { url: "https://example.com" }, workspace))
397+      .resolves.toBeUndefined()
398+  })
399+})