c737df3350849cafa41368138248f017159fc25a
- 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 1fdbc7e51f0d54356829d7a2173f7682c4f0a02e..bee627fd1a1be73b1303f2ac073290f929671a35 100644
3--- a/README.md
4+++ b/README.md
5@@ -62,7 +62,7 @@ The extension handles Pi's `tool_call` event and evaluates each tool call before
6
7 **Stage 3 — LLM** sends the tool input to the configured model with a security-focused prompt. Successful responses are cached; errors and unparseable responses become `ask` decisions and are not cached.
8
9-`webfetch` is deterministically allowed as read-only. Other tool types, including MCP tools, proceed to the cache and LLM stages. Shell syntax prevents deterministic allow matches, so compound Bash commands normally proceed to the LLM stage.
10diff --git a/src/config.ts b/src/config.ts
11index d483c7994e6ddbf8e4fe755d4b0e87014a757268..ffa817d4842d19a7cd43e04f034b5ae52319b313 100644
12--- a/src/config.ts
13+++ b/src/config.ts
14@@ -2,12 +2,23 @@ import { existsSync, readFileSync } from "node:fs"
15 import { homedir } from "node:os"
16 import { join } from "node:path"
17
18+export type PermissionAction = "allow" | "check" | "deny"
19+export type PermissionRules = PermissionAction | Record<string, PermissionAction>
20+
21+export type PermissionConfig = {
22+ bash?: PermissionRules
23+ edit?: PermissionRules
24+ external_directory?: PermissionRules
25+ webfetch?: PermissionRules
26+}
27+
28 export type PolicyEngineConfig = {
29 modelBackend: "openai" | "local"
30 llamaServer: {
31 baseUrl: string
32 model: string
33 }
34+ permission?: PermissionConfig
35 }
36
37 const DEFAULT_CONFIG: PolicyEngineConfig = {
38@@ -33,6 +44,30 @@ function stringValue(value: unknown): string | undefined {
39 return typeof value === "string" && value.trim() ? value.trim() : undefined
40 }
41
42+function permissionAction(value: unknown, field: string): PermissionAction {
43+ if (value === "allow" || value === "check" || value === "deny") return value
44+ throw new Error(`${field} must be "allow", "check", or "deny"`)
45+}
46+
47+function permissionRules(value: unknown, field: string): PermissionRules {
48+ if (typeof value === "string") return permissionAction(value, field)
49+ const data = objectValue(value)
50+ if (!data) throw new Error(`${field} must be a permission action or an object`)
51+
52+ return Object.fromEntries(
53+ Object.entries(data).map(([pattern, action]) => [pattern, permissionAction(action, `${field}.${pattern}`)]),
54+ )
55+}
56+
57+function permissionConfig(value: unknown, path: string): PermissionConfig {
58+ const data = objectValue(value)
59+ if (!data) throw new Error(`${path} must be a JSON object`)
60+
61+ return Object.fromEntries(
62+ Object.entries(data).map(([name, rules]) => [name, permissionRules(rules, `${path}.${name}`)]),
63+ ) as PermissionConfig
64+}
65+
66 export function loadConfig(): PolicyEngineConfig {
67 const path = configPath()
68 if (!existsSync(path)) return DEFAULT_CONFIG
69@@ -48,9 +83,11 @@ export function loadConfig(): PolicyEngineConfig {
70 const llamaServer = objectValue(data.llamaServer) ?? {}
71 const baseUrl = stringValue(llamaServer.baseUrl) ?? DEFAULT_CONFIG.llamaServer.baseUrl
72 const model = stringValue(llamaServer.model) ?? DEFAULT_CONFIG.llamaServer.model
73+ const permission = data.permission === undefined ? undefined : permissionConfig(data.permission, `${path}.permission`)
74
75 return {
76 modelBackend,
77 llamaServer: { baseUrl, model },
78+ permission,
79 }
80 }
81diff --git a/src/deterministic.ts b/src/deterministic.ts
82index c5f3d2d485fe0d657df4fab77323339e4f0bdacc..f6b16f240b141cde47f3c9416f3ed23e21f8d7a6 100644
83--- a/src/deterministic.ts
84+++ b/src/deterministic.ts
85@@ -80,7 +80,13 @@ export function checkDeterministic(
86 case "webfetch":
87 return {
88 decision: "allow",
89- reason: `WebFetch is read-only`,
90+ reason: "WebFetch is read-only",
91+ category: "web_read",
92+ }
93+ case "websearch":
94+ return {
95+ decision: "allow",
96+ reason: "WebSearch is read-only",
97 category: "web_read",
98 }
99 default:
100diff --git a/src/index.ts b/src/index.ts
101index 75ca445586c3dabb0ca0346e510d09fa2942cfd9..aeb09d709dde1023bab23c6b2be1eccf40d1b9d9 100644
102--- a/src/index.ts
103+++ b/src/index.ts
104@@ -4,6 +4,7 @@ import { captureProviderCredentials } from "./api"
105 import { lookupCache, writeCache } from "./cache"
106 import { checkDeterministic } from "./deterministic"
107 import { evaluateWithLLM } from "./llm"
108+import { checkStaticPermission } from "./static-permissions"
109 import { logDecision } from "./logger"
110 import { cacheKey, normalizeRequest } from "./normalizer"
111 import { notify } from "./notify"
112@@ -46,9 +47,11 @@ async function runPipelineSingle(
113 toolType: string,
114 input: ToolInput,
115 sessionId: string,
116+ cwd?: string,
117 ): Promise<PipelineResult> {
118 const start = performance.now()
119- const deterministic = checkDeterministic(toolType, input)
120+ const staticPermission = cwd ? await checkStaticPermission(toolType, input, cwd) : undefined
121+ const deterministic = staticPermission ?? checkDeterministic(toolType, input)
122 const normalized = normalizeRequest(toolType, input)
123
124 if (deterministic) {
125@@ -56,11 +59,11 @@ async function runPipelineSingle(
126 toolType,
127 normalized,
128 decision: deterministic,
129- source: "deterministic",
130+ source: staticPermission ? "static" : "deterministic",
131 timingMs: performance.now() - start,
132 sessionId,
133 })
134- return { decision: deterministic, source: "deterministic" }
135+ return { decision: deterministic, source: staticPermission ? "static" : "deterministic" }
136 }
137
138 const key = cacheKey(normalized)
139@@ -125,7 +128,7 @@ export default function (pi: ExtensionAPI) {
140 await capturePiCredentials(ctx)
141
142 const input = toolInput(event.input)
143- const result = await runPipelineSingle(event.toolName, input, ctx.sessionManager.getSessionId())
144+ const result = await runPipelineSingle(event.toolName, input, ctx.sessionManager.getSessionId(), ctx.cwd)
145 const { decision } = result
146
147 if (decision.decision === "allow") return undefined
148diff --git a/src/rules.ts b/src/rules.ts
149index 0565d525c0ea1b4d76d153602440f411137ed1df..4c1753235d47e3b8a9bad3c834ca8d21c1439c1b 100644
150--- a/src/rules.ts
151+++ b/src/rules.ts
152@@ -1,5 +1,5 @@
153 // Bump to invalidate all cached decisions when rules change.
154-export const POLICY_VERSION = 17;
155+export const POLICY_VERSION = 21;
156
157 // Trailing stderr redirections that are safe to strip before pattern matching.
158 // `2>&1` and `2>/dev/null` have no security implication but would otherwise
159diff --git a/src/static-permissions.ts b/src/static-permissions.ts
160new file mode 100644
161index 0000000000000000000000000000000000000000..18a19007c7d37864aa252b80d20b0e8eccdd4431
162--- /dev/null
163+++ b/src/static-permissions.ts
164@@ -0,0 +1,124 @@
165+import { realpath } from "node:fs/promises"
166+import { homedir } from "node:os"
167+import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
168+import { loadConfig, type PermissionAction, type PermissionConfig, type PermissionRules } from "./config"
169+import type { Decision } from "./types"
170+
171+const PATH_TOOLS = new Set(["read", "write", "edit", "find", "grep", "ls"])
172+
173+type ToolInput = Record<string, unknown>
174+
175+function patternRegex(pattern: string): RegExp {
176+ const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, "\\$&")
177+ return new RegExp(`^${escaped.replaceAll("*", ".*").replaceAll("?", ".")}$`)
178+}
179+
180+function expandedPattern(pattern: string): string {
181+ if (pattern === "~") return homedir()
182+ if (pattern.startsWith("~/")) return join(homedir(), pattern.slice(2))
183+ if (pattern === "$HOME") return homedir()
184+ if (pattern.startsWith("$HOME/")) return join(homedir(), pattern.slice(6))
185+ return pattern
186+}
187+
188+export function actionFor(rules: PermissionRules | undefined, value: string): PermissionAction | undefined {
189+ if (typeof rules === "string") return rules
190+ if (!rules) return undefined
191+
192+ let action: PermissionAction | undefined
193+ for (const [pattern, rule] of Object.entries(rules)) {
194+ if (patternRegex(expandedPattern(pattern)).test(value)) action = rule
195+ }
196+ return action
197+}
198+
199+async function canonicalPath(path: string, cwd: string): Promise<string> {
200+ const absolutePath = resolve(cwd, path.startsWith("@") ? path.slice(1) : path)
201+ const suffix: string[] = []
202+ let existingPath = absolutePath
203+
204+ while (true) {
205+ try {
206+ return join(await realpath(existingPath), ...suffix)
207+ } catch {
208+ const parentPath = dirname(existingPath)
209+ if (parentPath === existingPath) return absolutePath
210+ suffix.unshift(existingPath.slice(parentPath.length + 1))
211+ existingPath = parentPath
212+ }
213+ }
214+}
215+
216+function isInside(path: string, directory: string): boolean {
217+ const pathFromDirectory = relative(directory, path)
218+ return pathFromDirectory === "" || (!pathFromDirectory.startsWith(`..${sep}`) && pathFromDirectory !== ".." && !isAbsolute(pathFromDirectory))
219+}
220+
221+function staticDecision(
222+ action: PermissionAction | undefined,
223+ reason: string,
224+ category: string,
225+): Decision | undefined {
226+ if (!action || action === "check") return undefined
227+ return { decision: action, reason, category }
228+}
229+
230+function toolRules(toolType: string, config: PermissionConfig): PermissionRules | undefined {
231+ switch (toolType) {
232+ case "bash":
233+ return config.bash
234+ case "edit":
235+ case "write":
236+ return config.edit
237+ case "webfetch":
238+ return config.webfetch
239+ default:
240+ return undefined
241+ }
242+}
243+
244+function toolValue(toolType: string, input: ToolInput): string | undefined {
245+ switch (toolType) {
246+ case "bash":
247+ return typeof input.command === "string" ? input.command : undefined
248+ case "webfetch":
249+ return typeof input.url === "string" ? input.url : undefined
250+ default:
251+ return undefined
252+ }
253+}
254+
255+export async function checkStaticPermission(
256+ toolType: string,
257+ input: ToolInput,
258+ cwd: string,
259+): Promise<Decision | undefined> {
260+ const permission = loadConfig().permission
261+ if (!permission) return undefined
262+
263+ if (PATH_TOOLS.has(toolType)) {
264diff --git a/test/deterministic.test.ts b/test/deterministic.test.ts
265index 35be721a2b559611447b15bdc93e097defa57322..dcb3cdcfcf6143727cea32bf3b0dbc1cae85655a 100644
266--- a/test/deterministic.test.ts
267+++ b/test/deterministic.test.ts
268@@ -204,6 +204,12 @@ test("webfetch always allowed", () => {
269 expect(d!.decision).toBe("allow")
270 })
271
272+test("websearch always allowed", () => {
273+ const d = checkDeterministic("websearch", { query: "pi" })
274+ expect(d).toBeDefined()
275+ expect(d!.decision).toBe("allow")
276+})
277+
278 test("unknown tool type returns undefined", () => {
279 expect(checkDeterministic("mcp__linear__list", { foo: 1 })).toBeUndefined()
280 })
281diff --git a/test/extension.test.ts b/test/extension.test.ts
282index 6af2a120578878980d15f34ef34a8b805dc36892..b2b22d9c707e5d60e11a6260cb92c23ea4bd7ef2 100644
283--- a/test/extension.test.ts
284+++ b/test/extension.test.ts
285@@ -1,6 +1,24 @@
286-import { describe, expect, test } from "bun:test"
287+import { afterEach, beforeEach, describe, expect, test } from "bun:test"
288+import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
289+import { tmpdir } from "node:os"
290+import { join } from "node:path"
291 import PolicyEngine from "../src/index"
292
293+const originalConfigPath = process.env.PI_POLICY_ENGINE_CONFIG
294+let tempDir: string
295+
296+beforeEach(() => {
297+ tempDir = mkdtempSync(join(tmpdir(), "policy-engine-"))
298+ process.env.PI_POLICY_ENGINE_CONFIG = join(tempDir, "config.json")
299+ writeFileSync(process.env.PI_POLICY_ENGINE_CONFIG, "{}")
300+})
301+
302+afterEach(() => {
303+ rmSync(tempDir, { recursive: true, force: true })
304+ if (originalConfigPath === undefined) delete process.env.PI_POLICY_ENGINE_CONFIG
305+ else process.env.PI_POLICY_ENGINE_CONFIG = originalConfigPath
306+})
307+
308 type ToolCallHandler = (event: { toolName: string; input: Record<string, unknown> }, ctx: any) => Promise<unknown>
309
310 function loadToolCallHandler(): ToolCallHandler {
311@@ -31,6 +49,15 @@ describe("Pi policy extension", () => {
312 .resolves.toBeUndefined()
313 })
314
315+ test("runs check actions through deterministic rules", async () => {
316+ writeFileSync(process.env.PI_POLICY_ENGINE_CONFIG!, JSON.stringify({
317+ permission: { bash: { "*": "check" } },
318+ }))
319+ const handler = loadToolCallHandler()
320+ await expect(handler({ toolName: "bash", input: { command: "git status" } }, context(false)))
321+ .resolves.toBeUndefined()
322+ })
323+
324 test("blocks approval-required commands without a UI", async () => {
325 const handler = loadToolCallHandler()
326 await expect(handler({ toolName: "bash", input: { command: "sudo apt install foo" } }, context(false)))
327diff --git a/test/static-permissions.test.ts b/test/static-permissions.test.ts
328new file mode 100644
329index 0000000000000000000000000000000000000000..9f856baf81a0dbcde261700a3d464e5f1c9e8624
330--- /dev/null
331+++ b/test/static-permissions.test.ts
332@@ -0,0 +1,67 @@
333+import { afterEach, beforeEach, describe, expect, test } from "bun:test"
334+import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
335+import { tmpdir } from "node:os"
336+import { join } from "node:path"
337+import { actionFor, checkStaticPermission } from "../src/static-permissions"
338+
339+const originalConfigPath = process.env.PI_POLICY_ENGINE_CONFIG
340+let tempDir: string
341+let workspace: string
342+let externalDirectory: string
343+
344+beforeEach(() => {
345+ tempDir = mkdtempSync(join(tmpdir(), "policy-engine-"))
346+ workspace = join(tempDir, "workspace")
347+ externalDirectory = join(tempDir, "external")
348+ mkdirSync(workspace)
349+ mkdirSync(externalDirectory)
350+ process.env.PI_POLICY_ENGINE_CONFIG = join(tempDir, "config.json")
351+ writeFileSync(process.env.PI_POLICY_ENGINE_CONFIG, JSON.stringify({
352+ permission: {
353+ edit: "allow",
354+ bash: { "*sudo*": "deny", "*terraform apply*": "deny" },
355+ external_directory: { [`${externalDirectory}/*`]: "allow" },
356+ webfetch: "check",
357+ },
358+ }))
359+})
360+
361+afterEach(() => {
362+ rmSync(tempDir, { recursive: true, force: true })
363+ if (originalConfigPath === undefined) delete process.env.PI_POLICY_ENGINE_CONFIG
364+ else process.env.PI_POLICY_ENGINE_CONFIG = originalConfigPath
365+})
366+
367+describe("static permissions", () => {
368+ test("uses the last matching rule", () => {
369+ expect(actionFor({ "*": "check", "*sudo*": "deny" }, "sudo id")).toBe("deny")
370+ })
371+
372+ test("defaults to check when no static rule matches", async () => {
373+ await expect(checkStaticPermission("bash", { command: "git status" }, workspace))
374+ .resolves.toBeUndefined()
375+ await expect(checkStaticPermission("bash", { command: "sudo id" }, workspace))
376+ .resolves.toMatchObject({ decision: "deny" })
377+ await expect(checkStaticPermission("bash", { command: "terraform apply" }, workspace))
378+ .resolves.toMatchObject({ decision: "deny" })
379+ })
380+
381+ test("allows edits in the workspace and actions in allowed external directories", async () => {
382+ await expect(checkStaticPermission("edit", { path: "file.ts" }, workspace))
383+ .resolves.toMatchObject({ decision: "allow" })
384+ await expect(checkStaticPermission("write", { path: join(externalDirectory, "file.ts") }, workspace))
385+ .resolves.toMatchObject({ decision: "allow" })
386+ await expect(checkStaticPermission("read", { path: join(externalDirectory, "file.ts") }, workspace))
387+ .resolves.toMatchObject({ decision: "allow" })
388+ })
389+
390+ test("passes unlisted external paths to the policy pipeline", async () => {
391+ await expect(checkStaticPermission("read", { path: join(tempDir, "unlisted.txt") }, workspace))
392+ .resolves.toBeUndefined()
393+ })
394+
395+ test("passes webfetch checks to the policy pipeline", async () => {
396+ await expect(checkStaticPermission("webfetch", { url: "https://example.com" }, workspace))
397+ .resolves.toBeUndefined()
398+ })
399+})