feat: Per-project permission overrides for permission-gate
Untrusted repos could otherwise silently auto-allow commands by
committing config. Hash-based approval stored in ~/.pi/agent/ means
changes to .pi/permissions.json re-trigger user confirmation.

Assisted-by: Claude Opus 4.6 via pi
change lnnvtxmuruyzmuwxysrkrnzyvwmnnusq
commit 0cd0204c97d3b398a47aff21817930d33592192a
author Alpha Chen <alpha@kejadlen.dev>
date
parent snsououx
diff --git a/.pi/agent/extensions/permission-gate.ts b/.pi/agent/extensions/permission-gate.ts
index 37236333..2199efc9 100644
--- a/.pi/agent/extensions/permission-gate.ts
+++ b/.pi/agent/extensions/permission-gate.ts
@@ -6,11 +6,15 @@
  * 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.
  *
- * Allowed bash commands are configured declaratively in ALLOWED_COMMANDS below.
+ * Allowed bash commands are configured declaratively in BASE_COMMANDS below.
+ * Projects can add to the allowlist via `.pi/permissions.json`, which requires
+ * a one-time user confirmation (re-prompted if the file changes).
  */
 
 import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
 import { execSync } from "node:child_process";
+import { createHash } from "node:crypto";
+import * as fs from "node:fs";
 import * as os from "node:os";
 import * as path from "node:path";
 
@@ -33,11 +37,119 @@ import * as path from "node:path";
 // ---------------------------------------------------------------------------
 type CommandRule = true | string[] | ((args: string) => boolean) | { [subcommand: string]: CommandRule };
 
-const ALLOWED_COMMANDS: CommandRule = {
+/** JSON-safe subset of CommandRule (no predicates) for .pi/permissions.json */
+type JsonCommandRule = true | string[] | { [subcommand: string]: JsonCommandRule };
+
+const BASE_COMMANDS: CommandRule = {
   // version control (read-only subcommands)
   jj: ["diff", "log", "show", "status"],
 };
 
+// ---------------------------------------------------------------------------
+// Per-project permission overrides
+//
+// Projects can place a `.pi/permissions.json` in the project root:
+//
+//   { "allow": { "cargo": ["test", "check"], "make": ["test"] } }
+//
+// On first use (or when the file changes), the user is shown a summary
+// and asked to confirm. Approvals are stored in ~/.pi/agent/ keyed by
+// a hash of the file content.
+// ---------------------------------------------------------------------------
+const APPROVALS_PATH = path.join(os.homedir(), ".pi/agent/approved-permissions.json");
+
+interface ApprovalRecord {
+  hash: string;
+}
+
+function loadApprovals(): Record<string, ApprovalRecord> {
+  try {
+    return JSON.parse(fs.readFileSync(APPROVALS_PATH, "utf-8"));
+  } catch {
+    return {};
+  }
+}
+
+function saveApprovals(approvals: Record<string, ApprovalRecord>): void {
+  fs.mkdirSync(path.dirname(APPROVALS_PATH), { recursive: true });
+  fs.writeFileSync(APPROVALS_PATH, JSON.stringify(approvals, null, 2) + "\n");
+}
+
+function hashContent(content: string): string {
+  return createHash("sha256").update(content).digest("hex");
+}
+
+/**
+ * Render a command rule tree as a human-readable list of allowed command paths.
+ * e.g. { cargo: ["test", "check"] } → ["cargo test", "cargo check"]
+ */
+function summarizeRules(rule: JsonCommandRule, prefix = ""): string[] {
+  if (rule === true) return [prefix || "(all commands)"];
+  if (Array.isArray(rule)) return rule.map((s) => `${prefix} ${s}`.trim());
+  const lines: string[] = [];
+  for (const [key, sub] of Object.entries(rule)) {
+    lines.push(...summarizeRules(sub, `${prefix} ${key}`.trim()));
+  }
+  return lines;
+}
+
+/**
+ * Deep-merge a JSON rule tree into an existing CommandRule tree.
+ * Project rules can only add — they cannot override `true` with something
+ * more restrictive, and predicates in the base are preserved.
+ */
+function mergeRules(base: CommandRule, override: JsonCommandRule): CommandRule {
+  // base is already fully permissive — nothing to add
+  if (base === true) return true;
+  // override grants blanket access at this level
+  if (override === true) return true;
+
+  // base is a predicate or array — wrap into object form to merge
+  if (typeof base === "function" || Array.isArray(base)) {
+    // Can't cleanly merge a predicate/array with an object override.
+    // Convert base array to object, keep predicates as-is.
+    if (Array.isArray(base) && !Array.isArray(override)) {
+      const obj: { [k: string]: CommandRule } = {};
+      for (const k of base) obj[k] = true;
+      return mergeRules(obj, override);
+    }
+    if (Array.isArray(base) && Array.isArray(override)) {
+      return [...new Set([...base, ...override])];
+    }
+    // predicate base + object override — keep predicate (it's more expressive)
+    return base;
+  }
+
+  // Both are objects (or override is array)
+  if (Array.isArray(override)) {
+    // Convert override array to object and merge
+    const obj: { [k: string]: JsonCommandRule } = {};
+    for (const k of override) obj[k] = true;
+    return mergeRules(base, obj);
+  }
+
+  // Both are objects — recurse
+  const merged: { [k: string]: CommandRule } = { ...base };
+  for (const [key, val] of Object.entries(override)) {
+    if (key in merged) {
+      merged[key] = mergeRules(merged[key], val);
+    } else {
+      merged[key] = val;
+    }
+  }
+  return merged;
+}
+
+/** The effective merged command tree, updated after approval. */
+let allowedCommands: CommandRule = BASE_COMMANDS;
+
+/**
+ * Pending project rules awaiting user approval. Set at session start if the
+ * project has a .pi/permissions.json that hasn't been approved yet. Cleared
+ * after the user accepts or rejects.
+ */
+let pendingProjectRules: { raw: string; rules: JsonCommandRule } | null = null;
+
 // ---------------------------------------------------------------------------
 // Helpers for read-tool path checks
 // ---------------------------------------------------------------------------
@@ -111,12 +223,12 @@ function hasChaining(cmd: string): boolean {
 }
 
 /**
- * Walk the ALLOWED_COMMANDS tree to check if a command is allowed.
+ * Walk the allowedCommands tree to check if a command is allowed.
  * Arrays are shorthand for an object whose keys all map to `true`.
  */
 function isCommandAllowed(cmd: string): boolean {
   const tokens = cmd.trimStart().split(/\s+/);
-  let rule: CommandRule = ALLOWED_COMMANDS;
+  let rule: CommandRule = allowedCommands;
 
   for (let i = 0; i < tokens.length; i++) {
     if (rule === true) return true;
@@ -164,6 +276,41 @@ function isAllowed(toolName: string, input: Record<string, unknown>, ctx: Extens
 // ---------------------------------------------------------------------------
 
 export default function (pi: ExtensionAPI) {
+  pi.on("session_start", async (_event, ctx) => {
+    // Reset state each session
+    allowedCommands = BASE_COMMANDS;
+    pendingProjectRules = null;
+
+    const permPath = path.join(ctx.cwd, ".pi/permissions.json");
+    let raw: string;
+    try {
+      raw = fs.readFileSync(permPath, "utf-8");
+    } catch {
+      return; // No project permissions file
+    }
+
+    let projectRules: JsonCommandRule;
+    try {
+      const parsed = JSON.parse(raw);
+      projectRules = parsed.allow;
+      if (!projectRules) return;
+    } catch {
+      if (ctx.hasUI) ctx.ui.notify("Invalid .pi/permissions.json — ignoring", "warning");
+      return;
+    }
+
+    const hash = hashContent(raw);
+    const approvals = loadApprovals();
+
+    if (approvals[ctx.cwd]?.hash === hash) {
+      // Already approved this exact version
+      allowedCommands = mergeRules(BASE_COMMANDS, projectRules);
+    } else {
+      // Defer approval prompt to first tool_call (UI isn't ready here)
+      pendingProjectRules = { raw, rules: projectRules };
+    }
+  });
+
   pi.on("tool_result", async (event) => {
     if (event.toolName === "bash") {
       const cmd = String(event.input.command ?? "").trimStart();