0e2339a3c8359640694df3e3e0d35166447bb187

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

Message

Use prefixes for external directories

Diff

This diff is truncated to protect this page.

  1diff --git a/README.md b/README.md
  2index 7fa80ee6ea7ffbbb187f2823796fe0f8c4cace8e..191e2394e4d44c4220ea6d2d5aa88a8299660edf 100644
  3--- a/README.md
  4+++ b/README.md
  5@@ -90,12 +90,12 @@ Pi uses a root-level `tools` map and `externalDirectories` list. Any tool name c
  6     "my_extension_tool": "allow"
  7   },
  8   "externalDirectories": [
  9-    "/tmp/pi/*"
 10+    "/tmp/pi"
 11   ]
 12 }
 13 ```
 14 
 15-`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.
 16diff --git a/src/core/rules.ts b/src/core/rules.ts
 17index 99fb9316d600ec6a0cdcbe32bcc821a96ec917bc..2a04b35b333d744b5f73cb61c12d5c5afeed36fe 100644
 18--- a/src/core/rules.ts
 19+++ b/src/core/rules.ts
 20@@ -237,7 +237,7 @@ export function llmPolicyPrompt(
 21           .map((pattern) => `  - ${JSON.stringify(pattern)}`)
 22           .join("\n")}
 23 
 24-Treat these as trusted policy rules. Allow access when a tool call matches a listed path. This overrides the general directory guidance above.`;
 25+Treat these as trusted policy rules. Allow access when a tool-call path starts with a listed path. This overrides the general directory guidance above.`;
 26   return LLM_POLICY_PROMPT.replace(
 27     EXTERNAL_DIRECTORIES_PLACEHOLDER,
 28     directories,
 29diff --git a/src/pi/static-permissions.ts b/src/pi/static-permissions.ts
 30index 16914838013988cb884f2d72156befd072f13125..d40960bc80e1a09603967b22871ffa9131a92490 100644
 31--- a/src/pi/static-permissions.ts
 32+++ b/src/pi/static-permissions.ts
 33@@ -8,11 +8,6 @@ const PATH_TOOLS = new Set(["read", "write", "edit", "find", "grep", "ls"])
 34 
 35 type ToolInput = Record<string, unknown>
 36 
 37-function patternRegex(pattern: string): RegExp {
 38-  const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, "\\$&")
 39-  return new RegExp(`^${escaped.replaceAll("*", ".*").replaceAll("?", ".")}$`)
 40-}
 41-
 42 function expandedPattern(pattern: string): string {
 43   if (pattern === "~") return homedir()
 44   if (pattern.startsWith("~/")) return join(homedir(), pattern.slice(2))
 45@@ -22,7 +17,7 @@ function expandedPattern(pattern: string): string {
 46 }
 47 
 48 export function matchesExternalDirectory(directories: ExternalDirectories | undefined, value: string): boolean {
 49-  return directories?.some((pattern) => patternRegex(expandedPattern(pattern)).test(value)) ?? false
 50+  return directories?.some((directory) => value.startsWith(expandedPattern(directory))) ?? false
 51 }
 52 
 53 async function canonicalPath(path: string, cwd: string): Promise<string> {
 54diff --git a/test/pi/extension.test.ts b/test/pi/extension.test.ts
 55index 6930d592b7fa2658156a283f943a7181c8348915..6888f46910823eb663bccd20b051508380ff10e5 100644
 56--- a/test/pi/extension.test.ts
 57+++ b/test/pi/extension.test.ts
 58@@ -4,6 +4,7 @@ import { tmpdir } from "node:os"
 59 import { join } from "node:path"
 60 import PolicyEngine from "../../src/pi/index"
 61 import { loadPiPolicyConfig } from "../../src/pi/config"
 62+import { matchesExternalDirectory } from "../../src/pi/static-permissions"
 63 
 64 const originalAgentDirectory = process.env.PI_CODING_AGENT_DIR
 65 const originalKey = process.env.OPENAI_API_KEY
 66@@ -74,18 +75,18 @@ describe("Pi policy extension", () => {
 67   test("merges project configuration over global configuration", async () => {
 68     writeFileSync(join(tempDir, "policy-engine.json"), JSON.stringify({
 69       tools: { bash: "deny", global_tool: "allow" },
 70-      externalDirectories: ["/global/*"],
 71+      externalDirectories: ["/global"],
 72     }))
 73     const projectDirectory = join(tempDir, "project")
 74     mkdirSync(join(projectDirectory, ".pi"), { recursive: true })
 75     writeFileSync(join(projectDirectory, ".pi", "policy-engine.json"), JSON.stringify({
 76       tools: { bash: "allow", project_tool: "deny" },
 77-      externalDirectories: ["/project/*"],
 78+      externalDirectories: ["/project"],
 79     }))
 80 
 81     expect(loadPiPolicyConfig(projectDirectory)).toMatchObject({
 82       tools: { bash: "allow", global_tool: "allow", project_tool: "deny" },
 83-      externalDirectories: ["/global/*", "/project/*"],
 84+      externalDirectories: ["/global", "/project"],
 85     })
 86 
 87     const extension = loadExtension()
 88@@ -95,12 +96,18 @@ describe("Pi policy extension", () => {
 89       .resolves.toMatchObject({ block: true })
 90   })
 91 
 92+  test("matches external directories by prefix", () => {
 93+    expect(matchesExternalDirectory(["/tmp/pi"], "/tmp/pi/file")).toBe(true)
 94+    expect(matchesExternalDirectory(["/tmp/pi"], "/tmp/pilot/file")).toBe(true)
 95+    expect(matchesExternalDirectory(["/tmp/pi/*"], "/tmp/pi/file")).toBe(false)
 96+  })
 97+
 98   test("applies configured, deterministic, external-path, cached LLM, UI, and MCP policies", async () => {
 99     const externalDirectory = join(tempDir, "external")
100     mkdirSync(externalDirectory)
101     writeFileSync(join(tempDir, "policy-engine.json"), JSON.stringify({
102       tools: { custom_tool: "allow", blocked_tool: "deny", bash: "check", read: "allow" },
103-      externalDirectories: [`${externalDirectory}/*`],
104+      externalDirectories: [externalDirectory],
105     }))
106 
107     let reviews = 0
108@@ -126,7 +133,7 @@ describe("Pi policy extension", () => {
109     await expect(extension.toolCall({ toolName: "read", input: { path: externalFile } }, confirmed)).resolves.toBeUndefined()
110 
111     expect(reviews).toBe(1)
112-    expect(prompt).toContain(`- ${JSON.stringify(`${externalDirectory}/*`)}`)
113+    expect(prompt).toContain(`- ${JSON.stringify(externalDirectory)}`)
114     expect(extension.events).toEqual([
115       { name: "herdr:blocked", data: { active: true, label: "Policy approval required" } },
116       { name: "herdr:blocked", data: { active: false } },