permission-gate: Auto-allow reads of pi docs, derive skills from system prompt
Pi documentation lives outside the working tree and skills directories,
so every read triggered a confirmation prompt. The skills allowlist
parsed settings.json, missing skills added dynamically via
resources_discover; the system prompt reflects actual loaded skills.

Assisted-by: Claude claude-opus-4-6 via pi
change zmqylwqktnuynpqqooovzlsxotyqnkrn
commit 89372d278d3ec259f344bc61ec6d2db9e6a157cc
author Alpha Chen <alpha@kejadlen.dev>
date
parent yuwnzvoy
diff --git a/.pi/agent/extensions/permission-gate.ts b/.pi/agent/extensions/permission-gate.ts
index 05734a7b..091129f2 100644
--- a/.pi/agent/extensions/permission-gate.ts
+++ b/.pi/agent/extensions/permission-gate.ts
@@ -3,120 +3,120 @@
  *
  * Requires explicit user confirmation before every tool call.
  * Auto-allows read-only operations (read tool, non-destructive jj commands).
- * Auto-allows reading from tracked files and skills directories.
+ * Auto-allows reading from tracked files, skills directories, and pi docs.
  * Shows tool name and a summary of the arguments, then asks to allow or block.
  */
 
-import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
 import { execSync } from "node:child_process";
-import * as path from "node:path";
-import * as fs from "node:fs";
 import * as os from "node:os";
+import * as path from "node:path";
 
 const ALLOWED_JJ_SUBCOMMANDS = ["diff", "log", "show", "status"];
+const PI_DOCS_PREFIX = path.join(
+  os.homedir(),
+  ".volta/tools/image/packages/@mariozechner/pi-coding-agent/"
+);
 
 let trackedFiles: Set<string> | null = null;
-let skillsDirs: string[] | null = null;
+let skillsDirs: Set<string> | null = null;
 
 function getTrackedFiles(cwd: string): Set<string> {
-	if (trackedFiles) return trackedFiles;
-	try {
-		const output = execSync("jj file list", { cwd, encoding: "utf-8" });
-		trackedFiles = new Set(output.trim().split("\n").map((f) => path.resolve(cwd, f)));
-	} catch {
-		trackedFiles = new Set();
-	}
-	return trackedFiles;
+  if (trackedFiles) return trackedFiles;
+  try {
+    const output = execSync("jj file list", { cwd, encoding: "utf-8" });
+    trackedFiles = new Set(output.trim().split("\n").map((f) => path.resolve(cwd, f)));
+  } catch {
+    trackedFiles = new Set();
+  }
+  return trackedFiles;
 }
 
-function getSkillsDirectories(cwd: string): string[] {
-	if (skillsDirs) return skillsDirs;
-	try {
-		const settingsPath = path.join(cwd, ".pi", "agent", "settings.json");
-		const settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
-		skillsDirs = (settings.skills || []).map((skillPath: string) => {
-			// Handle ~ expansion
-			if (skillPath.startsWith("~/")) {
-				return path.resolve(os.homedir(), skillPath.slice(2));
-			}
-			return path.resolve(cwd, skillPath);
-		});
-	} catch {
-		skillsDirs = [];
-	}
-	return skillsDirs;
+/**
+ * Derive skills directories from the system prompt's <available_skills> block.
+ * This reflects the actual loaded skills (including any added by extensions via
+ * resources_discover), rather than just what's configured in settings.json.
+ */
+function getSkillsDirectories(ctx: ExtensionContext): Set<string> {
+  if (skillsDirs) return skillsDirs;
+  const dirs = new Set<string>();
+  const prompt = ctx.getSystemPrompt();
+  const locationRe = /<location>(.*?)<\/location>/g;
+  let match;
+  while ((match = locationRe.exec(prompt)) !== null) {
+    dirs.add(path.dirname(match[1]));
+  }
+  skillsDirs = dirs;
+  return skillsDirs;
 }
 
-function isInSkillsDirectory(filePath: string, cwd: string): boolean {
-	const skillsDirs = getSkillsDirectories(cwd);
-	return skillsDirs.some(skillsDir => filePath.startsWith(skillsDir + path.sep));
+function isInSkillsDirectory(filePath: string, ctx: ExtensionContext): boolean {
+  const dirs = getSkillsDirectories(ctx);
+  for (const dir of dirs) {
+    if (filePath.startsWith(dir + path.sep) || filePath === dir) return true;
+  }
+  return false;
 }
 
-function isAllowed(toolName: string, input: Record<string, unknown>, cwd: string): boolean {
-	if (toolName === "read") {
-		const filePath = path.resolve(cwd, String(input.path ?? ""));
-		return getTrackedFiles(cwd).has(filePath) || isInSkillsDirectory(filePath, cwd);
-	}
+function isAllowed(toolName: string, input: Record<string, unknown>, ctx: ExtensionContext): boolean {
+  if (toolName === "read") {
+    const filePath = path.resolve(ctx.cwd, String(input.path ?? ""));
+    return getTrackedFiles(ctx.cwd).has(filePath) || isInSkillsDirectory(filePath, ctx) || filePath.startsWith(PI_DOCS_PREFIX);
+  }
 
-	if (toolName === "bash") {
-		const cmd = String(input.command ?? "").trimStart();
-		if (cmd.startsWith("jj ")) {
-			const subcommand = cmd.slice(3).trimStart().split(/\s/)[0];
-			return ALLOWED_JJ_SUBCOMMANDS.includes(subcommand);
-		}
-	}
+  if (toolName === "bash") {
+    const cmd = String(input.command ?? "").trimStart();
+    if (cmd.startsWith("jj ")) {
+      const subcommand = cmd.slice(3).trimStart().split(/\s/)[0];
+      return ALLOWED_JJ_SUBCOMMANDS.includes(subcommand);
+    }
+  }
 
-	return false;
+  return false;
 }
 
 export default function (pi: ExtensionAPI) {
-	pi.on("tool_result", async (event) => {
-		if (event.toolName === "bash") {
-			const cmd = String(event.input.command ?? "").trimStart();
-			if (cmd.startsWith("jj ")) {
-				trackedFiles = null;
-			}
-		}
-		if (event.toolName === "write" || event.toolName === "edit") {
-			const filePath = String(event.input.path ?? "");
-			if (filePath.includes("settings.json")) {
-				skillsDirs = null;
-			}
-		}
-	});
+  pi.on("tool_result", async (event) => {
+    if (event.toolName === "bash") {
+      const cmd = String(event.input.command ?? "").trimStart();
+      if (cmd.startsWith("jj ")) {
+        trackedFiles = null;
+      }
+    }
+  });
 
-	pi.on("tool_call", async (event, ctx) => {
-		if (!ctx.hasUI) return;
-		if (isAllowed(event.toolName, event.input, ctx.cwd)) return;
+  pi.on("tool_call", async (event, ctx) => {
+    if (!ctx.hasUI) return;
+    if (isAllowed(event.toolName, event.input, ctx)) return;
 
-		const summary = formatArgs(event.toolName, event.input);
-		const allowed = await ctx.ui.confirm(event.toolName, summary);
+    const summary = formatArgs(event.toolName, event.input);
+    const allowed = await ctx.ui.confirm(event.toolName, summary);
 
-		if (!allowed) {
-			return { block: true, reason: "Blocked by user" };
-		}
-	});
+    if (!allowed) {
+      return { block: true, reason: "Blocked by user" };
+    }
+  });
 }
 
 function formatArgs(toolName: string, input: Record<string, unknown>): string {
-	switch (toolName) {
-		case "bash":
-			return `$ ${input.command}`;
-		case "read":
-			return `${input.path}` + (input.offset ? ` (offset ${input.offset})` : "");
-		case "write":
-			return `${input.path} (${String(input.content ?? "").length} chars)`;
-		case "edit":
-			return `${input.path}`;
-		default: {
-			const keys = Object.keys(input);
-			if (keys.length === 0) return "(no arguments)";
-			return keys.map((k) => `${k}: ${truncate(String(input[k]), 80)}`).join("\n");
-		}
-	}
+  switch (toolName) {
+    case "bash":
+      return `$ ${input.command}`;
+    case "read":
+      return `${input.path}` + (input.offset ? ` (offset ${input.offset})` : "");
+    case "write":
+      return `${input.path} (${String(input.content ?? "").length} chars)`;
+    case "edit":
+      return `${input.path}`;
+    default: {
+      const keys = Object.keys(input);
+      if (keys.length === 0) return "(no arguments)";
+      return keys.map((k) => `${k}: ${truncate(String(input[k]), 80)}`).join("\n");
+    }
+  }
 }
 
 function truncate(s: string, max: number): string {
-	if (s.length <= max) return s;
-	return s.slice(0, max) + "…";
+  if (s.length <= max) return s;
+  return s.slice(0, max) + "…";
 }