refactor: Make permission-gate command allowlist declarative and recursive
Replace hardcoded if/else chain with a recursive CommandRule tree that
supports objects (subcommand trees), arrays (shorthand for leaf sets),
predicates, and bare `true`. Adding a new allowed command or
subsubcommand is now a one-line table entry.

Also tightens chaining detection to block pipes and command substitution.

Assisted-by: Claude Opus 4.6 via pi
change snsououxulkxywvvypwwnxttkvzmnwul
commit c4c4224ad752be8ae634b7bf8868daf07b9f2e35
author Alpha Chen <alpha@kejadlen.dev>
date
parent osxlxnmw
diff --git a/.pi/agent/extensions/permission-gate.ts b/.pi/agent/extensions/permission-gate.ts
index 5d41675c..37236333 100644
--- a/.pi/agent/extensions/permission-gate.ts
+++ b/.pi/agent/extensions/permission-gate.ts
@@ -2,9 +2,11 @@
  * Permission Gate Extension
  *
  * Requires explicit user confirmation before every tool call.
- * Auto-allows read-only operations (read tool, non-destructive jj commands).
+ * Auto-allows read-only operations (read tool, non-destructive bash commands).
  * 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.
  */
 
 import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
@@ -12,7 +14,33 @@ import { execSync } from "node:child_process";
 import * as os from "node:os";
 import * as path from "node:path";
 
-const ALLOWED_JJ_SUBCOMMANDS = ["diff", "log", "show", "status"];
+// ---------------------------------------------------------------------------
+// Allowed bash commands configuration
+//
+// Each entry maps a command/subcommand to its permission rule, forming a tree:
+//
+//   true                          — allow with any further arguments
+//   string[]                      — shorthand: allow these subcommands (each as true)
+//   Record<string, CommandRule>   — recurse into subcommands
+//   (args: string) => boolean     — custom predicate on remaining args
+//
+// Examples:
+//   jj: ["diff", "log", "show", "status"],
+//   gh: {
+//     pr:    ["list", "view", "checks"],
+//     issue: ["list", "view"],
+//   },
+// ---------------------------------------------------------------------------
+type CommandRule = true | string[] | ((args: string) => boolean) | { [subcommand: string]: CommandRule };
+
+const ALLOWED_COMMANDS: CommandRule = {
+  // version control (read-only subcommands)
+  jj: ["diff", "log", "show", "status"],
+};
+
+// ---------------------------------------------------------------------------
+// Helpers for read-tool path checks
+// ---------------------------------------------------------------------------
 const PI_DOCS_PREFIX = path.join(
   os.homedir(),
   ".volta/tools/image/packages/@mariozechner/pi-coding-agent/"
@@ -58,37 +86,83 @@ function isInSkillsDirectory(filePath: string, ctx: ExtensionContext): boolean {
   return false;
 }
 
+// ---------------------------------------------------------------------------
+// Bash command parsing
+// ---------------------------------------------------------------------------
+
 /**
- * Strip a leading `cd <cwd> && ` or `cd <cwd> ; ` prefix from a command.
+ * Strip a leading `cd <cwd> && ` prefix from a command.
  * The agent often wraps commands this way, which is a no-op but obscures the
  * real command from permission checks.
  */
 function stripCdToCwd(cmd: string, cwd: string): string {
   const prefix = `cd ${cwd} && `;
   if (cmd.startsWith(prefix)) return cmd.slice(prefix.length);
-  const prefixSemi = `cd ${cwd} ; `;
-  if (cmd.startsWith(prefixSemi)) return cmd.slice(prefixSemi.length);
   return cmd;
 }
 
+/** Returns true if the command string contains shell chaining or piping operators. */
+function hasChaining(cmd: string): boolean {
+  // Block semicolons, &&, ||, pipes, and command substitution.
+  // This is intentionally conservative — if the agent needs chaining it can
+  // ask for permission on the full command.
+  return cmd.includes(";") || cmd.includes("&&") || cmd.includes("||")
+    || cmd.includes("|") || cmd.includes("$(") || cmd.includes("`");
+}
+
+/**
+ * Walk the ALLOWED_COMMANDS 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;
+
+  for (let i = 0; i < tokens.length; i++) {
+    if (rule === true) return true;
+    if (typeof rule === "function") return rule(tokens.slice(i).join(" "));
+    if (Array.isArray(rule)) return rule.includes(tokens[i]);
+    // rule is a subcommand tree — look up the next token
+    const next = rule[tokens[i]];
+    if (next === undefined) return false;
+    rule = next;
+  }
+
+  // We consumed all tokens — allow if the final node is `true` or a
+  // predicate that accepts empty input.  A tree node means we stopped at
+  // a branch (e.g. bare `jj` with no subcommand) — don't auto-allow that.
+  if (rule === true) return true;
+  if (typeof rule === "function") return rule("");
+  return false;
+}
+
+// ---------------------------------------------------------------------------
+// Main permission check
+// ---------------------------------------------------------------------------
+
 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);
+    return (
+      getTrackedFiles(ctx.cwd).has(filePath) ||
+      isInSkillsDirectory(filePath, ctx) ||
+      filePath.startsWith(PI_DOCS_PREFIX)
+    );
   }
 
   if (toolName === "bash") {
     const cmd = stripCdToCwd(String(input.command ?? "").trimStart(), ctx.cwd);
-    if (cmd.includes("&&") || cmd.includes(";")) return false;
-    if (cmd.startsWith("jj ")) {
-      const subcommand = cmd.slice(3).trimStart().split(/\s/)[0];
-      return ALLOWED_JJ_SUBCOMMANDS.includes(subcommand);
-    }
+    if (hasChaining(cmd)) return false;
+    return isCommandAllowed(cmd);
   }
 
   return false;
 }
 
+// ---------------------------------------------------------------------------
+// Extension entry point
+// ---------------------------------------------------------------------------
+
 export default function (pi: ExtensionAPI) {
   pi.on("tool_result", async (event) => {
     if (event.toolName === "bash") {
@@ -112,6 +186,10 @@ export default function (pi: ExtensionAPI) {
   });
 }
 
+// ---------------------------------------------------------------------------
+// Formatting
+// ---------------------------------------------------------------------------
+
 function formatArgs(toolName: string, input: Record<string, unknown>): string {
   switch (toolName) {
     case "bash":