98d73c93130380622d37f566728f6744e46db387

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

Message

Add project-scope config support

Diff

This diff is truncated to protect this page.

  1diff --git a/README.md b/README.md
  2index e138bc28ec3ae0def2aa4aa23ecf726e87dadc88..3ea145f1820750a11f4716453c6298a306c41ad5 100644
  3--- a/README.md
  4+++ b/README.md
  5@@ -32,7 +32,7 @@ OpenCode must ask for operations that this policy engine should evaluate. OpenCo
  6 
  7 ## Reviewer configuration
  8 
  9-Pi reads `$PI_CODING_AGENT_DIR/policy-engine.json`, or `PI_POLICY_ENGINE_CONFIG`. OpenCode reads `~/.config/opencode/policy-engine.json`, or `OPENCODE_POLICY_ENGINE_CONFIG`.
 10diff --git a/src/core/rules.ts b/src/core/rules.ts
 11index 0a49c5eea054b8615d1c3189c6704f84fd0e87db..81fd843812b47893cffc3b7371a77f5f5d6cd8cb 100644
 12--- a/src/core/rules.ts
 13+++ b/src/core/rules.ts
 14@@ -159,7 +159,7 @@ Ask only for:
 15 - Editing system (non-user) files, outside of the current working directory tree, or not on the list of allowed directories.
 16 - Remote infrastructure, data, access, or deployment mutation: create, update, delete, grant, revoke, apply, or execute a write on production databases, Kubernetes, or cloud providers.
 17 - Irreversibly destructive Git operations, such as reset --hard or checking out files.
 18-- Stopping the Herdr server: \`herdr server stop\` or directly doing a listed risk.
 19+- Stopping the Herdr server: \`herdr server stop\`
 20 - When a command attempts to search the entire (and only *THE ENTIRE*) home directory for something, since that's very slow and rarely needed.
 21 
 22diff --git a/src/pi/config.ts b/src/pi/config.ts
 23index 074ea665ae15dffc05ce24ead8a1121eda9aaeb8..ba2e0352b707e2b2908e40e70cbee21f5f564486 100644
 24--- a/src/pi/config.ts
 25+++ b/src/pi/config.ts
 26@@ -13,9 +13,12 @@ export type PiPolicyEngineConfig = {
 27   externalDirectories: ExternalDirectoryRules
 28 }
 29 
 30-function configPath(): string {
 31-  return process.env.PI_POLICY_ENGINE_CONFIG
 32-    ?? join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), "policy-engine.json")
 33+function globalConfigPath(): string {
 34+  return join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), "policy-engine.json")
 35+}
 36+
 37+function configPaths(cwd: string): string[] {
 38+  return [globalConfigPath(), join(cwd, ".pi", "policy-engine.json")]
 39 }
 40 
 41 export function piPolicyPaths(): { cacheFile: string; auditFile: string } {
 42@@ -66,8 +69,41 @@ export function parsePiPolicyConfig(value: unknown): PiPolicyEngineConfig {
 43   }
 44 }
 45 
 46-export function loadPiPolicyConfig(): PiPolicyEngineConfig {
 47-  const path = configPath()
 48-  if (!existsSync(path)) return { reviewer: parseReviewerConfig({}), tools: {}, externalDirectories: {} }
 49-  return parsePiPolicyConfig(JSON.parse(readFileSync(path, "utf8")))
 50+function mergeObjects(
 51+  base: Record<string, unknown> | undefined,
 52+  override: Record<string, unknown> | undefined,
 53+): Record<string, unknown> | undefined {
 54+  if (!base) return override
 55+  if (!override) return base
 56+  return { ...base, ...override }
 57+}
 58+
 59+function mergeConfig(
 60+  base: Record<string, unknown>,
 61+  override: Record<string, unknown>,
 62+): Record<string, unknown> {
 63+  const reviewer = mergeObjects(objectValue(base.reviewer), objectValue(override.reviewer))
 64+  const tools = mergeObjects(objectValue(base.tools), objectValue(override.tools))
 65+  const externalDirectories = mergeObjects(
 66+    objectValue(base.externalDirectories),
 67+    objectValue(override.externalDirectories),
 68+  )
 69+  return {
 70+    ...base,
 71+    ...override,
 72+    ...(reviewer && { reviewer }),
 73+    ...(tools && { tools }),
 74+    ...(externalDirectories && { externalDirectories }),
 75+  }
 76+}
 77+
 78+export function loadPiPolicyConfig(cwd = process.cwd()): PiPolicyEngineConfig {
 79+  let config: Record<string, unknown> = {}
 80+  for (const path of configPaths(cwd)) {
 81+    if (!existsSync(path)) continue
 82+    const source = JSON.parse(readFileSync(path, "utf8"))
 83+    parsePiPolicyConfig(source)
 84+    config = mergeConfig(config, source as Record<string, unknown>)
 85+  }
 86+  return parsePiPolicyConfig(config)
 87 }
 88diff --git a/src/pi/index.ts b/src/pi/index.ts
 89index eaba2f6d0569172544d56f540898cdd559ffeb5e..2aae435efbf215447ee4f73263b2d8427494fcba 100644
 90--- a/src/pi/index.ts
 91+++ b/src/pi/index.ts
 92@@ -55,7 +55,7 @@ function createPiPipeline(
 93   sessionBashAllowOverride: SessionBashAllowOverride | undefined,
 94   cwd: string,
 95 ) {
 96-  const config = loadPiPolicyConfig()
 97+  const config = loadPiPolicyConfig(cwd)
 98   const paths = piPolicyPaths()
 99   return createPolicyPipeline({
100     prechecks: [
101@@ -76,7 +76,7 @@ function createPiPipeline(
102       },
103       {
104         source: "static",
105-        decide: async (request) => checkStaticPermission(request.toolName, request.input, cwd),
106+        decide: async (request) => checkStaticPermission(request.toolName, request.input, cwd, config),
107       },
108     ],
109     deterministic: (request) => checkDeterministic(request.toolName, request.input),
110diff --git a/src/pi/static-permissions.ts b/src/pi/static-permissions.ts
111index b1a8c62ce1273b41a60ccf9f75e8624976c03e02..d8f56256f00412aa23043bb582ca094806d7d129 100644
112--- a/src/pi/static-permissions.ts
113+++ b/src/pi/static-permissions.ts
114@@ -1,7 +1,7 @@
115 import { realpath } from "node:fs/promises"
116 import { homedir } from "node:os"
117 import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
118-import { loadPiPolicyConfig, type ExternalDirectoryRules, type PermissionAction } from "./config"
119+import type { ExternalDirectoryRules, PermissionAction, PiPolicyEngineConfig } from "./config"
120 import type { Decision, DecisionCategory } from "../core/types"
121 
122 const PATH_TOOLS = new Set(["read", "write", "edit", "find", "grep", "ls"])
123@@ -62,8 +62,8 @@ export async function checkStaticPermission(
124   toolType: string,
125   input: ToolInput,
126   cwd: string,
127+  config: PiPolicyEngineConfig,
128 ): Promise<Decision | undefined> {
129-  const config = loadPiPolicyConfig()
130   const action = config.tools[toolType] ?? "check"
131 
132   let externalPath: string | undefined
133diff --git a/test/pi/extension.test.ts b/test/pi/extension.test.ts
134index 2e2c22e7174b3e7d8c54be4b31e381a17d8f9d8c..088869e4446c873f1d69c54be20ae6062c3e6ef5 100644
135--- a/test/pi/extension.test.ts
136+++ b/test/pi/extension.test.ts
137@@ -3,8 +3,9 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
138 import { tmpdir } from "node:os"
139 import { join } from "node:path"
140 import PolicyEngine from "../../src/pi/index"
141+import { loadPiPolicyConfig } from "../../src/pi/config"
142 
143-const originalConfigPath = process.env.PI_POLICY_ENGINE_CONFIG
144+const originalAgentDirectory = process.env.PI_CODING_AGENT_DIR
145 const originalKey = process.env.OPENAI_API_KEY
146 const originalFetch = globalThis.fetch
147 let tempDir: string
148@@ -12,15 +13,15 @@ let tempDir: string
149 afterEach(() => {
150   globalThis.fetch = originalFetch
151   rmSync(tempDir, { recursive: true, force: true })
152-  if (originalConfigPath === undefined) delete process.env.PI_POLICY_ENGINE_CONFIG
153-  else process.env.PI_POLICY_ENGINE_CONFIG = originalConfigPath
154+  if (originalAgentDirectory === undefined) delete process.env.PI_CODING_AGENT_DIR
155+  else process.env.PI_CODING_AGENT_DIR = originalAgentDirectory
156   if (originalKey === undefined) delete process.env.OPENAI_API_KEY
157   else process.env.OPENAI_API_KEY = originalKey
158 })
159 
160 beforeEach(() => {
161   tempDir = mkdtempSync(join(tmpdir(), "policy-engine-"))
162-  process.env.PI_POLICY_ENGINE_CONFIG = join(tempDir, "config.json")
163+  process.env.PI_CODING_AGENT_DIR = tempDir
164   process.env.OPENAI_API_KEY = "test-key"
165 })
166 
167@@ -54,22 +55,50 @@ function loadExtension() {
168   return { toolCall, sessionStart, allowBash, skipPermissions, entries, events }
169 }
170 
171-function context(hasUI = false, confirm: (title: string, message: string) => Promise<boolean> = async () => false) {
172+function context(
173+  hasUI = false,
174+  confirm: (title: string, message: string) => Promise<boolean> = async () => false,
175+  cwd = tempDir,
176+) {
177   return {
178     hasUI,
179     model: undefined,
180     modelRegistry: {},
181-    cwd: tempDir,
182+    cwd,
183     sessionManager: { getSessionId: () => "test-session", getBranch: () => [] },
184     ui: { confirm, notify() {} },
185   }
186 }
187 
188 describe("Pi policy extension", () => {
189+  test("merges project configuration over global configuration", async () => {
190+    writeFileSync(join(tempDir, "policy-engine.json"), JSON.stringify({
191+      tools: { bash: "deny", global_tool: "allow" },
192+      externalDirectories: { "/global/*": "allow" },
193+    }))
194+    const projectDirectory = join(tempDir, "project")
195+    mkdirSync(join(projectDirectory, ".pi"), { recursive: true })
196+    writeFileSync(join(projectDirectory, ".pi", "policy-engine.json"), JSON.stringify({
197+      tools: { bash: "allow", project_tool: "deny" },
198+      externalDirectories: { "/project/*": "deny" },
199+    }))
200+
201+    expect(loadPiPolicyConfig(projectDirectory)).toMatchObject({
202+      tools: { bash: "allow", global_tool: "allow", project_tool: "deny" },
203+      externalDirectories: { "/global/*": "allow", "/project/*": "deny" },
204+    })
205+
206+    const extension = loadExtension()
207+    await expect(extension.toolCall({ toolName: "global_tool", input: {} }, context(false, undefined, projectDirectory)))
208+      .resolves.toBeUndefined()
209+    await expect(extension.toolCall({ toolName: "project_tool", input: {} }, context(false, undefined, projectDirectory)))
210+      .resolves.toMatchObject({ block: true })
211+  })
212+
213   test("applies configured, deterministic, external-path, cached LLM, UI, and MCP policies", async () => {
214     const externalDirectory = join(tempDir, "external")
215     mkdirSync(externalDirectory)
216-    writeFileSync(process.env.PI_POLICY_ENGINE_CONFIG!, JSON.stringify({
217+    writeFileSync(join(tempDir, "policy-engine.json"), JSON.stringify({
218       tools: { custom_tool: "allow", blocked_tool: "deny", bash: "check", read: "allow" },
219       externalDirectories: { [`${externalDirectory}/*`]: "allow" },
220     }))
221@@ -156,7 +185,7 @@ describe("Pi policy extension", () => {
222   })
223 
224   test("skips LLM checks but keeps static and deterministic checks until reload", async () => {
225-    writeFileSync(process.env.PI_POLICY_ENGINE_CONFIG!, JSON.stringify({ tools: { blocked_tool: "deny" } }))
226+    writeFileSync(join(tempDir, "policy-engine.json"), JSON.stringify({ tools: { blocked_tool: "deny" } }))
227     let reviews = 0
228     globalThis.fetch = (async (_input, _init) => {
229       reviews += 1
230@@ -176,7 +205,7 @@ describe("Pi policy extension", () => {
231   })
232 
233   test("restores session Bash overrides on reload but not forks", async () => {
234-    writeFileSync(process.env.PI_POLICY_ENGINE_CONFIG!, JSON.stringify({ tools: { bash: "deny" } }))
235+    writeFileSync(join(tempDir, "policy-engine.json"), JSON.stringify({ tools: { bash: "deny" } }))
236     const original = loadExtension()