8637a2a41976b5dc6f2a0f8ae47f2d017f7ef6fe
- 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 47f78a58b28c6a2c8ac82d7f9e213caa832d3a0d..2b8079b7fc3778313754ab15c70690d922f62a12 100644
3--- a/README.md
4+++ b/README.md
5@@ -74,6 +74,25 @@ The legacy `modelBackend` and `llamaServer` configuration fields remain supporte
6
7 LLM review sends the complete tool input to the configured provider. Do not use a cloud reviewer when this is not acceptable.
8
9+## Pi permissions
10+
11+Pi uses root-level `tools` and `externalDirectories` maps. Any tool name can be configured. An omitted tool defaults to `check`.
12+
13+```json
14+{
15+ "tools": {
16+ "bash": "check",
17+ "read": "allow",
18+ "my_extension_tool": "allow"
19+ },
20+ "externalDirectories": {
21+ "/tmp/pi/*": "allow"
22+ }
23+}
24+```
25+
26+`externalDirectories` applies to Pi path tools. Its entries are also included in the LLM reviewer prompt so checked tools can approve matching external paths.
27+
28 ## Pi behavior
29
30 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.
31diff --git a/src/core/review.ts b/src/core/review.ts
32index 843169890702b775364a5d7f7fbd67c7dafffd11..30c49f4d17a0074ecee35acd083b2de7a4ffc96d 100644
33--- a/src/core/review.ts
34+++ b/src/core/review.ts
35@@ -1,4 +1,4 @@
36-import { LLM_POLICY_PROMPT } from "./rules"
37+import { llmPolicyPrompt } from "./rules"
38 import { callReviewer, type ApiKeyResolver } from "./providers"
39 import {
40 isDecisionCategory,
41@@ -42,6 +42,7 @@ function reviewRequest(request: PolicyRequest): string {
42 export function createPolicyReviewer(
43 config: ReviewerConfig,
44 resolveApiKey: ApiKeyResolver,
45+ externalDirectories?: Record<string, string>,
46 ): PolicyReviewer {
47 return {
48 async evaluate(request: PolicyRequest, context: PolicyContext): Promise<LLMEvaluationResult> {
49@@ -49,7 +50,7 @@ export function createPolicyReviewer(
50 const rawResponse = await callReviewer(
51 config,
52 [
53- { role: "system", content: LLM_POLICY_PROMPT },
54+ { role: "system", content: llmPolicyPrompt(externalDirectories) },
55 { role: "user", content: reviewRequest(request) },
56 ],
57 512,
58diff --git a/src/core/rules.ts b/src/core/rules.ts
59index 808793a67fcab30df43e118702d385ea0f71ab44..d52252ebc1307a47cdd4e7b566c0d51b26abe1ea 100644
60--- a/src/core/rules.ts
61+++ b/src/core/rules.ts
62@@ -185,3 +185,18 @@ Respond with ONLY this JSON:
63 "reason": "Brief explanation (1-2 sentences)",
64 "category": ${DECISION_CATEGORIES.map((category) => `"${category}"`).join(" | ")}
65 }`;
66+
67+export function llmPolicyPrompt(externalDirectories: Record<string, string> = {}): string {
68+ const entries = Object.entries(externalDirectories)
69+ if (entries.length === 0) return LLM_POLICY_PROMPT
70+
71+ const directories = entries
72+ .map(([pattern, action]) => `- ${JSON.stringify(pattern)}: ${action}`)
73+ .join("\n")
74+ return `${LLM_POLICY_PROMPT}
75+
76+Configured external-directory permissions:
77+${directories}
78+
79diff --git a/src/pi/config.ts b/src/pi/config.ts
80index 8e6bb22fdd825b349e6e21521b4e23f151183d62..074ea665ae15dffc05ce24ead8a1121eda9aaeb8 100644
81--- a/src/pi/config.ts
82+++ b/src/pi/config.ts
83@@ -5,23 +5,12 @@ import { parseReviewerConfig } from "../core/config"
84 import type { ReviewerConfig } from "../core/types"
85
86 export type PermissionAction = "allow" | "check" | "deny"
87-export type PermissionRules = PermissionAction | Record<string, PermissionAction>
88-
89-export type PermissionConfig = {
90- bash?: PermissionRules
91- read?: PermissionRules
92- write?: PermissionRules
93- edit?: PermissionRules
94- find?: PermissionRules
95- grep?: PermissionRules
96- ls?: PermissionRules
97- external_directory?: PermissionRules
98- webfetch?: PermissionRules
99-}
100+export type ExternalDirectoryRules = Record<string, PermissionAction>
101
102 export type PiPolicyEngineConfig = {
103 reviewer: ReviewerConfig
104- permission?: PermissionConfig
105+ tools: Record<string, PermissionAction>
106+ externalDirectories: ExternalDirectoryRules
107 }
108
109 function configPath(): string {
110@@ -48,34 +37,37 @@ function permissionAction(value: unknown, field: string): PermissionAction {
111 throw new Error(`${field} must be "allow", "check", or "deny"`)
112 }
113
114-function permissionRules(value: unknown, field: string): PermissionRules {
115- if (typeof value === "string") return permissionAction(value, field)
116+function toolPermissions(value: unknown): Record<string, PermissionAction> {
117 const data = objectValue(value)
118- if (!data) throw new Error(`${field} must be a permission action or an object`)
119+ if (!data) throw new Error("tools must be a JSON object")
120 return Object.fromEntries(
121- Object.entries(data).map(([pattern, action]) => [pattern, permissionAction(action, `${field}.${pattern}`)]),
122+ Object.entries(data).map(([name, action]) => [name, permissionAction(action, `tools.${name}`)]),
123 )
124 }
125
126-function permissionConfig(value: unknown): PermissionConfig {
127+function externalDirectoryRules(value: unknown): ExternalDirectoryRules {
128 const data = objectValue(value)
129- if (!data) throw new Error("permission must be a JSON object")
130+ if (!data) throw new Error("externalDirectories must be a JSON object")
131 return Object.fromEntries(
132- Object.entries(data).map(([name, rules]) => [name, permissionRules(rules, `permission.${name}`)]),
133- ) as PermissionConfig
134+ Object.entries(data).map(([pattern, action]) => [pattern, permissionAction(action, `externalDirectories.${pattern}`)]),
135+ )
136 }
137
138 export function parsePiPolicyConfig(value: unknown): PiPolicyEngineConfig {
139 const data = objectValue(value)
140 if (!data) throw new Error("policy engine configuration must be a JSON object")
141+ if (data.permission !== undefined) {
142+ throw new Error("permission has been renamed to tools; external_directory has been renamed to externalDirectories")
143+ }
144 return {
145 reviewer: parseReviewerConfig(data),
146- permission: data.permission === undefined ? undefined : permissionConfig(data.permission),
147+ tools: data.tools === undefined ? {} : toolPermissions(data.tools),
148+ externalDirectories: data.externalDirectories === undefined ? {} : externalDirectoryRules(data.externalDirectories),
149 }
150 }
151
152 export function loadPiPolicyConfig(): PiPolicyEngineConfig {
153 const path = configPath()
154- if (!existsSync(path)) return { reviewer: parseReviewerConfig({}) }
155+ if (!existsSync(path)) return { reviewer: parseReviewerConfig({}), tools: {}, externalDirectories: {} }
156 return parsePiPolicyConfig(JSON.parse(readFileSync(path, "utf8")))
157 }
158diff --git a/src/pi/index.ts b/src/pi/index.ts
159index e5a0202b9a4498445c2b82a50b6d97391ecf26dd..ee1d4a13ddb416857075379119a41da4e69cce4d 100644
160--- a/src/pi/index.ts
161+++ b/src/pi/index.ts
162@@ -67,7 +67,7 @@ function createPiPipeline(
163 deterministic: (request) => checkDeterministic(request.toolName, request.input),
164 cache: createJsonlDecisionCache(paths.cacheFile),
165 audit: createJsonlDecisionAudit(paths.auditFile),
166- reviewer: createPolicyReviewer(config.reviewer, resolvePiOpenAIKey),
167+ reviewer: createPolicyReviewer(config.reviewer, resolvePiOpenAIKey, config.externalDirectories),
168 normalize: (request) => normalizeRequest(request.toolName, request.input),
169 cacheKey,
170 inputSummary: auditInputSummary,
171diff --git a/src/pi/static-permissions.ts b/src/pi/static-permissions.ts
172index 6da6d2b3db49622ec69423d730ced1778c00fe1e..b1a8c62ce1273b41a60ccf9f75e8624976c03e02 100644
173--- a/src/pi/static-permissions.ts
174+++ b/src/pi/static-permissions.ts
175@@ -1,12 +1,10 @@
176 import { realpath } from "node:fs/promises"
177 import { homedir } from "node:os"
178 import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
179-import { loadPiPolicyConfig, type PermissionAction, type PermissionConfig, type PermissionRules } from "./config"
180+import { loadPiPolicyConfig, type ExternalDirectoryRules, type PermissionAction } from "./config"
181 import type { Decision, DecisionCategory } from "../core/types"
182
183-const STATIC_PERMISSION_TOOLS = ["bash", "read", "write", "edit", "find", "grep", "ls", "webfetch"] as const
184-type StaticPermissionTool = (typeof STATIC_PERMISSION_TOOLS)[number]
185-const PATH_TOOLS = new Set<StaticPermissionTool>(["read", "write", "edit", "find", "grep", "ls"])
186+const PATH_TOOLS = new Set(["read", "write", "edit", "find", "grep", "ls"])
187
188 type ToolInput = Record<string, unknown>
189
190@@ -23,8 +21,7 @@ function expandedPattern(pattern: string): string {
191 return pattern
192 }
193
194-export function actionFor(rules: PermissionRules | undefined, value: string): PermissionAction | undefined {
195- if (typeof rules === "string") return rules
196+export function actionFor(rules: ExternalDirectoryRules | undefined, value: string): PermissionAction | undefined {
197 if (!rules) return undefined
198
199 let action: PermissionAction | undefined
200@@ -56,58 +53,37 @@ function isInside(path: string, directory: string): boolean {
201 return pathFromDirectory === "" || (!pathFromDirectory.startsWith(`..${sep}`) && pathFromDirectory !== ".." && !isAbsolute(pathFromDirectory))
202 }
203
204-function staticDecision(
205- action: PermissionAction | undefined,
206- reason: string,
207- category: DecisionCategory,
208-): Decision | undefined {
209+function staticDecision(action: PermissionAction | undefined, reason: string, category: DecisionCategory): Decision | undefined {
210 if (!action || action === "check") return undefined
211 return { decision: action, reason, category }
212 }
213
214-function isStaticPermissionTool(toolType: string): toolType is StaticPermissionTool {
215- return (STATIC_PERMISSION_TOOLS as readonly string[]).includes(toolType)
216-}
217-
218-function toolRules(toolType: StaticPermissionTool, config: PermissionConfig): PermissionRules | undefined {
219- return config[toolType]
220-}
221-
222-function toolValue(toolType: StaticPermissionTool, input: ToolInput): string | undefined {
223- if (toolType === "bash") return typeof input.command === "string" ? input.command : undefined
224- if (toolType === "webfetch") return typeof input.url === "string" ? input.url : undefined
225- return undefined
226-}
227-
228 export async function checkStaticPermission(
229 toolType: string,
230 input: ToolInput,
231 cwd: string,
232 ): Promise<Decision | undefined> {
233- const permission = loadPiPolicyConfig().permission
234- if (!permission) return undefined
235-
236- if (!isStaticPermissionTool(toolType)) return undefined
237+ const config = loadPiPolicyConfig()
238+ const action = config.tools[toolType] ?? "check"
239
240+ let externalPath: string | undefined
241 if (PATH_TOOLS.has(toolType)) {
242 const path = typeof input.path === "string" ? input.path : "."
243 const absolutePath = await canonicalPath(path, cwd)
244 const workspacePath = await canonicalPath(cwd, cwd)
245- const external = !isInside(absolutePath, workspacePath)
246- if (external) {
247- const action = actionFor(permission.external_directory, absolutePath) ?? "check"
248- if (action === "check") return undefined
249- if (action === "deny") return { decision: "deny", reason: `External path: ${absolutePath}`, category: "external_directory" }
250+ if (!isInside(absolutePath, workspacePath)) {
251+ const externalAction = actionFor(config.externalDirectories, absolutePath) ?? "check"
252+ if (externalAction === "deny") {
253+ return { decision: "deny", reason: `External path: ${absolutePath}`, category: "external_directory" }
254+ }
255+ if (externalAction === "check") return undefined
256+ externalPath = absolutePath
257 }
258- const result = staticDecision(actionFor(toolRules(toolType, permission), absolutePath), `Static ${toolType} permission`, toolType)
259- if (result) return result
260- return external
261- ? { decision: "allow", reason: `Static external path permission: ${absolutePath}`, category: "external_directory" }
262- : undefined
263 }
264
265- const value = toolValue(toolType, input)
266- return value
267- ? staticDecision(actionFor(toolRules(toolType, permission), value), `Static ${toolType} permission`, toolType)
268+ const toolDecision = staticDecision(action, `Static ${toolType} permission`, "config_allow")
269+ if (toolDecision) return toolDecision
270+ return externalPath
271+ ? { decision: "allow", reason: `Static external path permission: ${externalPath}`, category: "external_directory" }
272 : undefined
273 }