56906139ade6308121c5ca15cb7baa00fa7b452d
- 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 3ea145f1820750a11f4716453c6298a306c41ad5..7fa80ee6ea7ffbbb187f2823796fe0f8c4cace8e 100644
3--- a/README.md
4+++ b/README.md
5@@ -80,7 +80,7 @@ LLM review sends the complete tool input to the configured provider. Do not use
6
7 ## Pi permissions
8
9-Pi uses root-level `tools` and `externalDirectories` maps. Any tool name can be configured. An omitted tool defaults to `check`.
10+Pi uses a root-level `tools` map and `externalDirectories` list. Any tool name can be configured. An omitted tool defaults to `check`.
11
12 ```json
13 {
14@@ -89,13 +89,13 @@ Pi uses root-level `tools` and `externalDirectories` maps. Any tool name can be
15 "read": "allow",
16 "my_extension_tool": "allow"
17 },
18- "externalDirectories": {
19- "/tmp/pi/*": "allow"
20- }
21+ "externalDirectories": [
22+ "/tmp/pi/*"
23+ ]
24 }
25 ```
26
27-`externalDirectories` applies to Pi path tools. Its entries are also included in the LLM reviewer prompt so checked tools can approve matching external paths.
28+`externalDirectories` applies to Pi path tools. Every listed path is allowed. Its entries are also included in the LLM reviewer prompt so checked tools can allow matching external paths.
29
30 ## Pi behavior
31
32diff --git a/src/core/deterministic.ts b/src/core/deterministic.ts
33index 4d4a3fa0277cb149b3a7c10164c41e1be297fb1e..ae1dadcff4a135053ccf2d098b92cfbd21da1456 100644
34--- a/src/core/deterministic.ts
35+++ b/src/core/deterministic.ts
36@@ -28,6 +28,145 @@ function checkHardAllow(command: string): Decision | undefined {
37 return undefined
38 }
39
40+function shellWords(command: string): string[] {
41+ return (command.match(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^\s]+/g) ?? []).map((word) => {
42+ if ((word.startsWith('"') && word.endsWith('"')) || (word.startsWith("'") && word.endsWith("'"))) {
43+ return word.slice(1, -1)
44+ }
45+ return word
46+ })
47+}
48+
49+function isSecretPath(word: string): boolean {
50+ const path = word.replace(/\/+$/, "")
51+ if (/(?:^|\/)\.env[A-Za-z0-9._-]*(?:\/|$)/i.test(path)) return true
52+ if (/(?:^|\/)(?:credentials?|tokens?)(?:[._-][A-Za-z0-9_-]+)*(?:\/|$)/i.test(path)) return true
53+
54+ const home = process.env.HOME
55+ const homePrefix = home ? `(?:~|${home.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})` : "~"
56+ return new RegExp(
57+ `^${homePrefix}/(?:\\.(?:aws|ssh|azure|kube|gnupg|oci)(?:/|$)|\\.config/(?:gcloud|gh|hub|azure|doctl|rclone|sops|containers)(?:/|$)|\\.local/share/keyrings(?:/|$)|\\.docker/config\\.json$|\\.(?:netrc|npmrc|pypirc|git-credentials)$)`,
58+ "i",
59+ ).test(path)
60+}
61+
62+function positionalWords(words: string[], valueOptions: ReadonlySet<string> = new Set()): string[] {
63+ const positional: string[] = []
64+ let options = true
65+ for (let index = 0; index < words.length; index += 1) {
66+ const word = words[index]
67+ if (options && word === "--") {
68+ options = false
69+ continue
70+ }
71+ if (options && word.startsWith("-")) {
72+ if (valueOptions.has(word)) index += 1
73+ continue
74+ }
75+ positional.push(word)
76+ }
77+ return positional
78+}
79+
80+function hasSecretOptionValue(arguments_: string[], optionNames: ReadonlySet<string>): boolean {
81+ for (let index = 0; index < arguments_.length; index += 1) {
82+ const word = arguments_[index]
83+ const [option, value] = word.split("=", 2)
84+ if (value !== undefined && optionNames.has(option) && isSecretPath(value)) return true
85+ if (optionNames.has(word) && isSecretPath(arguments_[index + 1] ?? "")) return true
86+ if (word.startsWith("-f") && word.length > 2 && isSecretPath(word.slice(2))) return true
87+ }
88+ return false
89+}
90+
91+function hasSecretPathOperand(command: string): boolean {
92+ const [program, ...arguments_] = shellWords(command)
93+ if (!program) return false
94+
95+ const trustedLanguageToolchains = new Set([
96diff --git a/src/core/review.ts b/src/core/review.ts
97index ee550dc0825946af2b4c4f2ee38f408b82096d48..5c10e3d6a2fda2a118130c72eda9a4cc5ee4fc7e 100644
98--- a/src/core/review.ts
99+++ b/src/core/review.ts
100@@ -46,7 +46,7 @@ function reviewRequest(request: PolicyRequest, context: PolicyContext): string {
101 export function createPolicyReviewer(
102 config: ReviewerConfig,
103 resolveApiKey: ApiKeyResolver,
104- externalDirectories?: Record<string, string>,
105+ externalDirectories?: readonly string[],
106 ): PolicyReviewer {
107 return {
108 async evaluate(request: PolicyRequest, context: PolicyContext): Promise<LLMEvaluationResult> {
109diff --git a/src/core/rules.ts b/src/core/rules.ts
110index 81fd843812b47893cffc3b7371a77f5f5d6cd8cb..1a9fca6c5de6ffb552cc19564eac8c81006ff800 100644
111--- a/src/core/rules.ts
112+++ b/src/core/rules.ts
113@@ -1,7 +1,7 @@
114 import { DECISION_CATEGORIES } from "./types";
115
116 // Bump to invalidate all cached decisions when rules change.
117-export const POLICY_VERSION = 33;
118+export const POLICY_VERSION = 34;
119
120 // Trailing stderr redirections that are safe to strip before pattern matching.
121 // `2>&1` and `2>/dev/null` have no security implication but would otherwise
122@@ -47,6 +47,7 @@ export const HARD_ALLOW_PATTERNS: RegExp[] = [
123 /^which\s+[A-Za-z0-9._-]+\s*$/,
124 /^whereis\s+[A-Za-z0-9._-]+\s*$/,
125 /^type\s+[A-Za-z0-9._-]+\s*$/,
126+ /^command\s+-v\s+[A-Za-z0-9][A-Za-z0-9._+-]*(?:\s+[A-Za-z0-9][A-Za-z0-9._+-]*)*\s*$/,
127 // ls with relative or absolute paths (block hidden files, traversal).
128 // ls only prints filenames, no content leakage.
129 /^ls(?:\s+-[A-Za-z]+)*(?:\s+(?!.*\/\.)(?!.*\.\.)\/?[A-Za-z0-9][A-Za-z0-9._/-]*)*\s*$/,
130@@ -55,18 +56,49 @@ export const HARD_ALLOW_PATTERNS: RegExp[] = [
131 // base64 with relative paths only
132 /^base64\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*(?:\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*)*\s*$/,
133 // Bare base64 reads from an already-evaluated pipe.
134- /^base64(?:\s+-d)?\s*$/,
135- // head/tail with relative paths and line limits
136- /^(head|tail)(?:\s+-(?:n\s*)?\d+)?(?:\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*)*\s*$/,
137+ /^base64(?:\s+(?:-d|--decode))?\s*$/,
138+ // Bounded head/tail reads from stdin or guarded local paths.
139+ /^(head|tail)(?:\s+-(?:n\s*)?\d{1,6})?(?:\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*)*\s*$/,
140+ /^head\s+-c\s+\d{1,6}(?:\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*)?\s*$/,
141+ /^tail\s+-n\s+\+\d{1,6}(?:\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*)?\s*$/,
142+ /^nl\s+-ba(?:\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*)?\s*$/,
143+ // `sed -n` with numeric print ranges only; no file writes or command execution.
144+ /^sed\s+-n\s+["']?\d{1,7}(?:,\d{1,7})?p(?:;\d{1,7}(?:,\d{1,7})?p)*["']?(?:\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*)?\s*$/,
145 // find read-only on relative paths
146 /^find\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*(?:\s+(?:-name|-iname)\s+["'][^"']+["']|\s+-type\s+(?:["']?[fdlbcps]["']?))*\s*$/,
147- // grep on relative paths
148+ // grep and bare rg searches on guarded local paths.
149 /^grep(?:\s+-[A-Za-z]+)*\s+(?:["'][^"']+["']|[A-Za-z0-9._-]+)(?:\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*)*\s*$/,
150+ /^rg\s+(?:["'][^"']+["']|[A-Za-z0-9._:-]+)(?:\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*)*\s*$/,
151 // `cut -f` without a file operand reads stdin from an already-evaluated pipe.
152 /^cut\s+-f\s*\d[\d,-]*\s*$/,
153+ /^cut\s+-d(?:'[^']*'|"[^"]*"|[^\s])\s+-f\s*\d[\d,-]*\s*$/,
154+ /^tr\s+-d\s+(?:'[^']*'|"[^"]*"|[^\s])\s*$/,
155 /^ps\s*$/,
156 /^lsof\s*$/,
157
158+ // Package metadata queries.
159+ /^pacman\s+-(?:Q|Ql|Si)(?:\s+[A-Za-z0-9@._+:-]+)*\s*$/,
160+ /^pacman\s+-Qo\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*\s*$/,
161+ /^rpm\s+-qa\s*$/,
162+ /^dpkg-query\s+-W(?:\s+[A-Za-z0-9@._+:-]+)*\s*$/,
163+ /^npm\s+ls(?:\s+(?:--(?:all|json|long|parseable|global)|--depth(?:=\d+|\s+\d+)|[A-Za-z0-9@._+/-]+))*\s*$/,
164+ /^npm\s+view\s+[A-Za-z0-9@._+/-]+(?:\s+(?:version|versions|dist-tags|description|repository|license|engines|dependencies|peerDependencies|devDependencies))?(?:\s+--json)?\s*$/,
165+
166+ // Local metadata and manifest inspection.
167+ /^id\s+-(?:u|g)(?:\s+[A-Za-z0-9._-]+)?\s*$/,
168+ /^date\s+\+[A-Za-z0-9%:_-]+\s*$/,
169+ /^(?:file|readlink\s+-f|dirname|strings)\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*\s*$/,
170+ /^jar\s+tf\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*\s*$/,
171+ /^getent\s+group(?:\s+[A-Za-z0-9._-]+)?\s*$/,
172+
173+ // Docker query forms only.
174+ /^docker\s+--version\s*$/,
175+ /^docker\s+manifest\s+inspect(?:\s+--verbose)?\s+[A-Za-z0-9][A-Za-z0-9._/@:-]*\s*$/,
176+ /^docker\s+compose\s+ps(?:\s+(?:-a|--all|-q|--quiet))?\s*$/,
177+ /^docker\s+compose\s+logs(?:\s+(?:--tail\s+\d{1,6}|--timestamps))?\s*$/,
178+ /^docker\s+(?:image\s+inspect|inspect)\s+[A-Za-z0-9][A-Za-z0-9._/@:-]*\s*$/,
179+ /^systemctl\s+--user\s+is-(?:active|enabled)\s+[A-Za-z0-9@._-]+\s*$/,
180+
181 // Version checks
182 /^(node|npm|pnpm|yarn|bun|python|python3|go|cargo|rustc|java|javac)\s+(--version|-v|-V)\s*$/,
183 /^uv\s+(--version|version)\s*$/,
184@@ -88,7 +120,6 @@ export const CONFIG_ALLOW_PATTERNS: RegExp[] = [
185 // Tools without stricter HARD_ALLOW equivalents
186 /^echo\s/,
187 /^jq\s/,
188- /^rg\s/,
189 /^sort(?:\s|$)/,
190 /^stat\s/,
191 /^tc\s/,
192@@ -128,8 +159,9 @@ export const CONFIG_ALLOW_PATTERNS: RegExp[] = [
193 /^uv\s+run\s+(mypy|ruff)(?:\s|$)/,
194 /^uv\s+run\s+-w\s+ddgs\s+python\s/,
195
196- // Bun
197- /^(?:TZ=\S+\s+)?bun\s/,
198+ // Local language toolchains are trusted. Explicit secret-path operands are
199+ // still intercepted before this allowlist.
200+ /^(?:bun|bunx|node|npm|npx|pnpm|yarn|yarnpkg|deno|python|python3|pip|pip3|uv|poetry|go|cargo|rustc|mvn|gradle|\.\/gradlew|java|javac|kotlinc|dotnet|ruby|bundle|rails|php|composer|elixir|mix|swift|scala|sbt|Rscript)(?:\s|$)/,
201 ];
202
203 // Searched (not anchored) — obviously dangerous patterns that warrant user confirmation.
204@@ -146,6 +178,8 @@ export const ASK_PATTERNS: RegExp[] = [
205 // cannot receive a deterministic allow before their command nodes are evaluated.
206 export const SHELL_CONTROL_RE = /[|;&`<>\r\n]|\$\(|\$\{/;
207
208+const EXTERNAL_DIRECTORIES_PLACEHOLDER = "<<EXTERNAL_DIRECTORIES>>";
209+
210 export const LLM_POLICY_PROMPT = `Classify the serialized tool call as allow or ask.
211
212 Decision procedure:
213diff --git a/src/pi/config.ts b/src/pi/config.ts
214index ba2e0352b707e2b2908e40e70cbee21f5f564486..c8c746b405bf1250815b2f4ea0ac3128047d0877 100644
215--- a/src/pi/config.ts
216+++ b/src/pi/config.ts
217@@ -5,12 +5,12 @@ import { parseReviewerConfig } from "../core/config"
218 import type { ReviewerConfig } from "../core/types"
219
220 export type PermissionAction = "allow" | "check" | "deny"
221-export type ExternalDirectoryRules = Record<string, PermissionAction>
222+export type ExternalDirectories = string[]
223
224 export type PiPolicyEngineConfig = {
225 reviewer: ReviewerConfig
226 tools: Record<string, PermissionAction>
227- externalDirectories: ExternalDirectoryRules
228+ externalDirectories: ExternalDirectories
229 }
230
231 function globalConfigPath(): string {
232@@ -48,12 +48,11 @@ function toolPermissions(value: unknown): Record<string, PermissionAction> {
233 )
234 }
235
236-function externalDirectoryRules(value: unknown): ExternalDirectoryRules {
237- const data = objectValue(value)
238- if (!data) throw new Error("externalDirectories must be a JSON object")
239- return Object.fromEntries(
240- Object.entries(data).map(([pattern, action]) => [pattern, permissionAction(action, `externalDirectories.${pattern}`)]),
241- )
242+function externalDirectories(value: unknown): ExternalDirectories {
243+ if (!Array.isArray(value) || !value.every((path) => typeof path === "string")) {
244+ throw new Error("externalDirectories must be a JSON array of strings")
245+ }
246+ return value
247 }
248
249 export function parsePiPolicyConfig(value: unknown): PiPolicyEngineConfig {
250@@ -65,7 +64,7 @@ export function parsePiPolicyConfig(value: unknown): PiPolicyEngineConfig {
251 return {
252 reviewer: parseReviewerConfig(data),
253 tools: data.tools === undefined ? {} : toolPermissions(data.tools),
254- externalDirectories: data.externalDirectories === undefined ? {} : externalDirectoryRules(data.externalDirectories),
255+ externalDirectories: data.externalDirectories === undefined ? [] : externalDirectories(data.externalDirectories),
256 }
257 }
258
259@@ -78,15 +77,21 @@ function mergeObjects(
260 return { ...base, ...override }
261 }
262
263+function mergeArrays(base: string[] | undefined, override: string[] | undefined): string[] | undefined {
264+ if (!base) return override
265+ if (!override) return base
266+ return [...new Set([...base, ...override])]
267+}
268+
269 function mergeConfig(
270 base: Record<string, unknown>,
271 override: Record<string, unknown>,
272 ): Record<string, unknown> {
273 const reviewer = mergeObjects(objectValue(base.reviewer), objectValue(override.reviewer))
274 const tools = mergeObjects(objectValue(base.tools), objectValue(override.tools))
275- const externalDirectories = mergeObjects(
276- objectValue(base.externalDirectories),
277- objectValue(override.externalDirectories),
278+ const externalDirectories = mergeArrays(
279+ Array.isArray(base.externalDirectories) ? base.externalDirectories as string[] : undefined,
280+ Array.isArray(override.externalDirectories) ? override.externalDirectories as string[] : undefined,
281 )
282 return {
283 ...base,
284diff --git a/src/pi/static-permissions.ts b/src/pi/static-permissions.ts
285index d8f56256f00412aa23043bb582ca094806d7d129..16914838013988cb884f2d72156befd072f13125 100644
286--- a/src/pi/static-permissions.ts
287+++ b/src/pi/static-permissions.ts
288@@ -1,7 +1,7 @@
289 import { realpath } from "node:fs/promises"
290 import { homedir } from "node:os"
291 import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
292-import type { ExternalDirectoryRules, PermissionAction, PiPolicyEngineConfig } from "./config"
293+import type { ExternalDirectories, PermissionAction, PiPolicyEngineConfig } from "./config"
294 import type { Decision, DecisionCategory } from "../core/types"
295
296 const PATH_TOOLS = new Set(["read", "write", "edit", "find", "grep", "ls"])
297@@ -21,14 +21,8 @@ function expandedPattern(pattern: string): string {
298 return pattern
299 }
300
301-export function actionFor(rules: ExternalDirectoryRules | undefined, value: string): PermissionAction | undefined {
302- if (!rules) return undefined
303-
304- let action: PermissionAction | undefined
305- for (const [pattern, rule] of Object.entries(rules)) {
306- if (patternRegex(expandedPattern(pattern)).test(value)) action = rule
307- }
308- return action
309+export function matchesExternalDirectory(directories: ExternalDirectories | undefined, value: string): boolean {
310+ return directories?.some((pattern) => patternRegex(expandedPattern(pattern)).test(value)) ?? false
311 }
312
313 async function canonicalPath(path: string, cwd: string): Promise<string> {
314@@ -72,11 +66,7 @@ export async function checkStaticPermission(
315 const absolutePath = await canonicalPath(path, cwd)
316 const workspacePath = await canonicalPath(cwd, cwd)
317 if (!isInside(absolutePath, workspacePath)) {
318- const externalAction = actionFor(config.externalDirectories, absolutePath) ?? "check"
319- if (externalAction === "deny") {
320- return { decision: "deny", reason: `External path: ${absolutePath}`, category: "external_directory" }
321- }
322- if (externalAction === "check") return undefined
323+ if (!matchesExternalDirectory(config.externalDirectories, absolutePath)) return undefined
324 externalPath = absolutePath
325 }
326 }
327diff --git a/test/core/deterministic.test.ts b/test/core/deterministic.test.ts
328index 08687870ac2ecacc901bba2f7c813aae483c7a07..b6a48bd5eb8a23dca9f91db0189355aac511c497 100644
329--- a/test/core/deterministic.test.ts
330+++ b/test/core/deterministic.test.ts
331@@ -16,3 +16,105 @@ test("allows sh syntax checks without restricting the source path", () => {
332 test("keeps shell execution subject to confirmation", () => {
333 expect(checkDeterministic("bash", { command: "bash script.sh -n" })).toMatchObject({ decision: "ask" })
334 })
335+
336+test("allows recognized read-only command grammars", () => {
337+ for (const command of [
338+ "command -v bun node",
339+ "bun test",
340+ "bunx eslint src",
341+ "npx tsc --noEmit",
342+ "./gradlew test",
343+ "cargo test",
344+ "nl -ba src/core/rules.ts",
345+ "sed -n '10,20p' src/core/rules.ts",
346+ "pacman -Ql bash",
347+ "rpm -qa",
348+ "dpkg-query -W bash",
349+ "npm ls --depth=0",
350+ "npm view bun version --json",
351+ "head -c 512 README.md",
352+ "tail -n +10 README.md",
353+ "id -u",
354+ "date +%Y-%m-%d",
355+ "file README.md",
356+ "readlink -f README.md",
357+ "dirname src/core/rules.ts",
358+ "strings package.json",
359+ "jar tf app.jar",
360+ "getent group",
361+ "docker --version",
362+ "docker manifest inspect nginx:latest",
363+ "docker compose ps --all",
364+ "docker compose logs --tail 100",
365+ "docker image inspect nginx:latest",
366+ "docker inspect local-container",
367+ "systemctl --user is-active pi.service",
368+ "base64 --decode",
369+ "tr -d '\\n'",
370+ "cut -d: -f1,3",
371+ ]) {
372+ expect(checkDeterministic("bash", { command })).toMatchObject({ decision: "allow" })
373+ }
374+})
375+
376+test("allows local Docker operations except volume deletion", () => {
377+ for (const command of [
378+ "docker compose up -d",
379+ "docker compose config",
380+ "docker build -t app .",
381+ "docker run --rm app",
382+ "docker volume ls",
383+ ]) {
384+ expect(checkDeterministic("bash", { command })).toMatchObject({ decision: "allow" })
385+ }
386+
387+ for (const command of [
388+ "docker volume rm app-data",
389+ "docker volume prune -f",
390+ "docker rm --volumes app",
391+ "docker compose down -v",
392+ "docker system prune --volumes",
393+ ]) {
394+ expect(checkDeterministic("bash", { command })).toMatchObject({ decision: "ask" })
395+ }
396+})
397+
398+test("asks before allowing reads from recognized secret paths", () => {
399+ const home = process.env.HOME ?? "/home/user"
400+ for (const command of [
401+ "cat .env",
402+ "head -n 10 config/.env.production",
403+ "cat .envrc",
404+ `cat ${home}/.aws/credentials`,
405+ "cat ~/.ssh/id_ed25519",
406+ `rg token ${home}/.config/gcloud/application_default_credentials.json`,
407+ "rg --files .env",
408+ "rg -f .env .env",
409+ "rg --file=.env .env",
410+ "xargs cat .env",
411+ "bun .env",
412+ "bun --env-file=.env run script.ts",
413+ "node .env",
414+ "npm --prefix .env test",
415+ ]) {
416+ expect(checkDeterministic("bash", { command })).toMatchObject({ decision: "ask" })
417+ }
418+})
419+
420+test("does not treat search terms as secret paths", () => {
421+ expect(checkDeterministic("bash", { command: "rg '.env' README.md" })).toMatchObject({ decision: "allow" })
422+})
423+
424+test("does not deterministically allow non-toolchain mutation or shell-control variants", () => {
425+ for (const command of [
426+ "kustomize build --enable-helm deploy",
427+ "systemctl --user restart pi.service",
428+ "sed -n '1e id' README.md",
429+ "kustomize build --enable-helm deploy",
430+ "kustomize build deploy",
431diff --git a/test/core/review.test.ts b/test/core/review.test.ts
432index 9f02a48e4ef33dadb59222e25d82238e9e71d19b..2cfc4a5cad2ac2d5a1376ddf6f43c62712cf442b 100644
433--- a/test/core/review.test.ts
434+++ b/test/core/review.test.ts
435@@ -1,8 +1,14 @@
436 import { expect, test } from "bun:test"
437 import { llmPolicyPrompt } from "../../src/core/rules"
438
439-test("formats external-directory permissions in the reviewer prompt", () => {
440- expect(llmPolicyPrompt({ "/tmp/pi/*": "allow" })).toContain(
441- "- \"/tmp/pi/*\": allow",
442- )
443+test("inserts external-directory permissions before the decision instructions", () => {
444+ const prompt = llmPolicyPrompt(["/tmp/pi/*"])
445+
446+ expect(prompt).toContain("- \"/tmp/pi/*\"")
447+ expect(prompt.indexOf("- \"/tmp/pi/*\"")).toBeLessThan(prompt.indexOf("Never ask because"))
448+ expect(prompt).not.toContain("<<EXTERNAL_DIRECTORIES>>")
449+})
450+
451+test("renders a placeholder list when no external directories are configured", () => {
452+ expect(llmPolicyPrompt()).toContain("- No additional directories.")
453 })
454diff --git a/test/pi/extension.test.ts b/test/pi/extension.test.ts
455index 088869e4446c873f1d69c54be20ae6062c3e6ef5..6930d592b7fa2658156a283f943a7181c8348915 100644
456--- a/test/pi/extension.test.ts
457+++ b/test/pi/extension.test.ts
458@@ -74,18 +74,18 @@ describe("Pi policy extension", () => {
459 test("merges project configuration over global configuration", async () => {
460 writeFileSync(join(tempDir, "policy-engine.json"), JSON.stringify({
461 tools: { bash: "deny", global_tool: "allow" },
462- externalDirectories: { "/global/*": "allow" },
463+ externalDirectories: ["/global/*"],
464 }))
465 const projectDirectory = join(tempDir, "project")
466 mkdirSync(join(projectDirectory, ".pi"), { recursive: true })
467 writeFileSync(join(projectDirectory, ".pi", "policy-engine.json"), JSON.stringify({
468 tools: { bash: "allow", project_tool: "deny" },
469- externalDirectories: { "/project/*": "deny" },
470+ externalDirectories: ["/project/*"],
471 }))
472
473 expect(loadPiPolicyConfig(projectDirectory)).toMatchObject({
474 tools: { bash: "allow", global_tool: "allow", project_tool: "deny" },
475- externalDirectories: { "/global/*": "allow", "/project/*": "deny" },
476+ externalDirectories: ["/global/*", "/project/*"],
477 })
478
479 const extension = loadExtension()
480@@ -100,7 +100,7 @@ describe("Pi policy extension", () => {
481 mkdirSync(externalDirectory)
482 writeFileSync(join(tempDir, "policy-engine.json"), JSON.stringify({
483 tools: { custom_tool: "allow", blocked_tool: "deny", bash: "check", read: "allow" },
484- externalDirectories: { [`${externalDirectory}/*`]: "allow" },
485+ externalDirectories: [`${externalDirectory}/*`],
486 }))
487
488 let reviews = 0
489@@ -126,7 +126,7 @@ describe("Pi policy extension", () => {
490 await expect(extension.toolCall({ toolName: "read", input: { path: externalFile } }, confirmed)).resolves.toBeUndefined()
491
492 expect(reviews).toBe(1)
493- expect(prompt).toContain(`- ${JSON.stringify(`${externalDirectory}/*`)}: allow`)
494+ expect(prompt).toContain(`- ${JSON.stringify(`${externalDirectory}/*`)}`)
495 expect(extension.events).toEqual([
496 { name: "herdr:blocked", data: { active: true, label: "Policy approval required" } },
497 { name: "herdr:blocked", data: { active: false } },