fix: Defer project permission prompt to first tool_call
UI is not interactive during session_start, so the confirm dialog
renders but cannot accept input.

Assisted-by: Claude Opus 4.6 via pi
change uzstlyymkpuqyxqmpnpzrynvlsnnzlvw
commit 5299d4fb5bc42b5c49c142b216c87787ca1bf197
author Alpha Chen <alpha@kejadlen.dev>
date
parent mxvtllsr
diff --git a/.pi/agent/extensions/README.md b/.pi/agent/extensions/README.md
new file mode 100644
index 00000000..e5f6dd7f
--- /dev/null
+++ b/.pi/agent/extensions/README.md
@@ -0,0 +1,109 @@
+# Permission Gate
+
+Requires explicit user confirmation before every tool call, with
+declarative allowlists for commands that can run without prompting.
+
+## How It Works
+
+**Read tool** — auto-allowed for files tracked by `jj`, files in skills
+directories, and pi documentation.
+
+**Bash tool** — auto-allowed only if the command matches the allowlist
+and contains no shell chaining (`|`, `&&`, `;`, `` ` ``, `$()`).
+
+Everything else prompts for confirmation.
+
+## Base Allowlist
+
+The `BASE_COMMANDS` tree in `permission-gate.ts` defines the global
+allowlist. Each entry maps a command to a rule:
+
+```ts
+const BASE_COMMANDS: CommandRule = {
+  jj: ["diff", "log", "show", "status"],
+};
+```
+
+### Rule Types
+
+| Rule | Meaning | Example |
+|------|---------|---------|
+| `true` | Allow with any arguments | `ls: true` |
+| `string[]` | Allow only these subcommands | `jj: ["diff", "log"]` |
+| `{ ... }` | Recurse into subcommands | `gh: { pr: ["list", "view"] }` |
+| `(args) => boolean` | Custom predicate | `rake: (a) => a.startsWith("-T")` |
+
+Rules are recursive — arrays and objects can nest to any depth:
+
+```ts
+{
+  gh: {
+    pr:    ["list", "view", "checks"],
+    issue: { list: true, view: true },
+    repo:  true,                        // all repo subcommands
+  },
+}
+```
+
+`gh pr list --author me` walks `gh` → `pr` → `list` → `true` → allowed.
+`gh pr create` walks `gh` → `pr` → `create` → undefined → prompted.
+
+## Per-Project Overrides
+
+Projects can extend the allowlist by placing a `.pi/permissions.json`
+in the project root (next to `.pi/`):
+
+```json
+{
+  "allow": {
+    "cargo": ["test", "check", "clippy"],
+    "make": ["test", "lint"],
+    "docker": {
+      "compose": ["up", "down", "ps"]
+    }
+  }
+}
+```
+
+### Trust Model
+
+Project overrides are **not trusted automatically**. On the first tool
+call of a session (or when the file changes), the user sees a
+confirmation prompt:
+
+```
+Project wants to auto-allow:
+  cargo test
+  cargo check
+  cargo clippy
+  make test
+  make lint
+  docker compose up
+  docker compose down
+  docker compose ps
+
+Allow? [y/n]
+```
+
+Approvals are persisted in `~/.pi/agent/approved-permissions.json`,
+keyed by project directory and a SHA-256 hash of the file content.
+If `.pi/permissions.json` is modified, the hash won't match and the
+user is re-prompted.
+
+### Merge Behavior
+
+Project rules are **additive only** — they are deep-merged with the
+base allowlist. A project cannot narrow or remove base permissions.
+If the base already grants `true` at some node, the project's rules
+for that node are ignored (it's already fully allowed).
+
+### JSON-Only
+
+Project permissions use `JsonCommandRule`, which supports `true`,
+`string[]`, and nested objects — but not function predicates (since
+the file is JSON). Predicates are only available in `BASE_COMMANDS`.
+
+### Non-Interactive Mode
+
+If pi is running without a UI (e.g., RPC or print mode), project
+permissions that haven't been previously approved are silently skipped.
diff --git a/.pi/agent/extensions/permission-gate.ts b/.pi/agent/extensions/permission-gate.ts
index 2199efc9..d0db68db 100644
--- a/.pi/agent/extensions/permission-gate.ts
+++ b/.pi/agent/extensions/permission-gate.ts
@@ -322,6 +322,24 @@ export default function (pi: ExtensionAPI) {
 
   pi.on("tool_call", async (event, ctx) => {
     if (!ctx.hasUI) return;
+
+    // Prompt for pending project permissions on first tool_call
+    if (pendingProjectRules) {
+      const pending = pendingProjectRules;
+      pendingProjectRules = null; // Clear so we only prompt once
+
+      const summary = summarizeRules(pending.rules);
+      const message = "Project wants to auto-allow:\n" + summary.map((s) => `  ${s}`).join("\n");
+      const approved = await ctx.ui.confirm("Project permissions", message);
+
+      if (approved) {
+        const approvals = loadApprovals();
+        approvals[ctx.cwd] = { hash: hashContent(pending.raw) };
+        saveApprovals(approvals);
+        allowedCommands = mergeRules(BASE_COMMANDS, pending.rules);
+      }
+    }
+
     if (isAllowed(event.toolName, event.input, ctx)) return;
 
     const summary = formatArgs(event.toolName, event.input);