98d73c93130380622d37f566728f6744e46db387
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
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()