56906139ade6308121c5ca15cb7baa00fa7b452d

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

Message

Broaden local command policy

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 } },