Commit b7108c5c7ff0

Vincent Demeester <vincent@sbr.pm>
2026-02-19 14:05:23
feat(guardrails): add scoped approval for dangerous commands
Added turn and session-scoped approval bypass to the dangerous command confirmation dialog. Users can now choose "Yes, for this turn" or "Yes, for this session" to avoid repeated prompts for the same command type. Also fixed TypeScript type errors by using isToolCallEventType for proper input narrowing.
1 parent 20db1ed
Changed files (1)
dots
pi
agent
extensions
guardrails
dots/pi/agent/extensions/guardrails/index.ts
@@ -12,6 +12,7 @@
  */
 
 import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
 import { existsSync } from "node:fs";
 import { readFile, writeFile } from "node:fs/promises";
 import { join, normalize } from "node:path";
@@ -184,6 +185,17 @@ const DEFAULT_POLICIES: PathPolicy[] = [
 	},
 ];
 
+// ── Approval bypass types ─────────────────────────────────────
+
+type ApprovalScope = "session" | "turn";
+
+interface ApprovalBypass {
+	/** Which dangerous command description was approved */
+	desc: string;
+	/** How long the approval lasts */
+	scope: ApprovalScope;
+}
+
 // ── Extension entry point ─────────────────────────────────────
 
 export default function (pi: ExtensionAPI) {
@@ -191,6 +203,25 @@ export default function (pi: ExtensionAPI) {
 	let policies: PathPolicy[] = DEFAULT_POLICIES;
 	let configLoaded = false;
 
+	// Track approved dangerous commands by scope
+	const approvedCommands: ApprovalBypass[] = [];
+
+	function isApproved(desc: string): boolean {
+		return approvedCommands.some((a) => a.desc === desc);
+	}
+
+	function clearTurnApprovals(): void {
+		for (let i = approvedCommands.length - 1; i >= 0; i--) {
+			if (approvedCommands[i].scope === "turn") {
+				approvedCommands.splice(i, 1);
+			}
+		}
+	}
+
+	function clearAllApprovals(): void {
+		approvedCommands.length = 0;
+	}
+
 	async function loadPolicies(): Promise<void> {
 		if (configLoaded) return;
 		try {
@@ -288,23 +319,33 @@ export default function (pi: ExtensionAPI) {
 
 	pi.on("tool_call", async (event, ctx) => {
 		await loadPolicies();
-		const toolName = event.toolName?.toLowerCase() || "";
 
 		// ── Bash command security ──
-		if (toolName === "bash") {
-			const command = (event.input.command as string) || "";
+		if (isToolCallEventType("bash", event)) {
+			const command = event.input.command;
 			const commandForMatching = stripQuotedContent(command);
 
 			// Check dangerous commands (confirm before allowing)
 			for (const { pattern, desc } of dangerousCommands) {
 				if (pattern.test(commandForMatching)) {
+					// Skip prompt if already approved for this scope
+					if (isApproved(desc)) break;
+
 					if (!ctx.hasUI) {
 						return { block: true, reason: `Blocked ${desc} (no UI to confirm)` };
 					}
-					const ok = await ctx.ui.confirm(`⚠️ Dangerous command: ${desc}`, command);
-					if (!ok) {
+					const choice = await ctx.ui.select(
+						`⚠️ Dangerous command: ${desc}\n\n  ${command}\n\nAllow?`,
+						["Yes", "Yes, for this turn", "Yes, for this session", "No"],
+					);
+					if (!choice || choice === "No") {
 						return { block: true, reason: `Blocked ${desc} by user` };
 					}
+					if (choice === "Yes, for this turn") {
+						approvedCommands.push({ desc, scope: "turn" });
+					} else if (choice === "Yes, for this session") {
+						approvedCommands.push({ desc, scope: "session" });
+					}
 					break;
 				}
 			}
@@ -321,12 +362,13 @@ export default function (pi: ExtensionAPI) {
 		}
 
 		// ── Write/Edit path validation ──
+		const toolName = event.toolName?.toLowerCase() || "";
 		if (!["write", "edit", "notebookedit"].includes(toolName)) {
 			return undefined;
 		}
 
-		const input = event.input || {};
-		const filePath = input.file_path || input.filePath || input.path || input.notebook_path;
+		const input = event.input as Record<string, unknown>;
+		const filePath = (input.file_path || input.filePath || input.path || input.notebook_path) as string | undefined;
 		if (!filePath || typeof filePath !== "string") return undefined;
 
 		const normalizedPath = normalize(filePath);
@@ -389,6 +431,18 @@ export default function (pi: ExtensionAPI) {
 		return undefined;
 	});
 
+	// ── Turn/session lifecycle: clear approvals ──────────────
+
+	pi.on("agent_start", async () => {
+		// New user prompt → clear turn-scoped approvals
+		clearTurnApprovals();
+	});
+
+	pi.on("session_start", async () => {
+		// New or restored session → clear all approvals
+		clearAllApprovals();
+	});
+
 	// ── Commands ──────────────────────────────────────────────
 
 	pi.registerCommand("path-policies", {
@@ -457,7 +511,7 @@ export default function (pi: ExtensionAPI) {
 		handler: async (_args, ctx) => {
 			configLoaded = false;
 			await loadPolicies();
-			ctx.ui.notify(`Loaded ${policies.length} path policies`, "success");
+			ctx.ui.notify(`Loaded ${policies.length} path policies`, "info");
 		},
 	});
 }