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
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":