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
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);