Commit b7108c5c7ff0
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");
},
});
}