me like nix
1/**
2 * Permission Extension for pi-coding-agent
3 *
4 * Implements layered permission control.
5 *
6 * Interactive mode:
7 * Use `/permission` command to view or change the level.
8 * Use `/permission-mode` to switch between ask vs block.
9 * When changing via command, you'll be asked: session-only or global?
10 *
11 * Print mode (pi -p):
12 * Set PI_PERMISSION_LEVEL env var: PI_PERMISSION_LEVEL=medium pi -p "task"
13 * Operations beyond level will exit with helpful error message.
14 * Use PI_PERMISSION_LEVEL=bypassed for CI/containers (dangerous!)
15 *
16 * Levels:
17 * minimal - Read-only mode (default)
18 * ✅ Read files, ls, grep, git status/log/diff
19 * ❌ No file modifications, no commands with side effects
20 *
21 * low - File operations only
22 * ✅ Create/edit files in project directory
23 * ❌ No package installs, no git commits, no builds
24 *
25 * medium - Development operations
26 * ✅ npm/pip install, git commit/pull, make/build
27 * ❌ No git push, no sudo, no production changes
28 *
29 * high - Full operations
30 * ✅ git push, deployments, scripts
31 * ⚠️ Still prompts for destructive commands (rm -rf, etc.)
32 *
33 * Usage:
34 * pi --extension ./permission-hook.ts
35 *
36 * Or add to ~/.pi/agent/extensions/ or .pi/extensions/ for automatic loading.
37 */
38
39import { exec } from "node:child_process";
40import fs from "node:fs";
41import os from "node:os";
42import path from "node:path";
43import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
44import {
45 type PermissionLevel,
46 type PermissionMode,
47 LEVELS,
48 LEVEL_INDEX,
49 LEVEL_INFO,
50 LEVEL_ALLOWED_DESC,
51 PERMISSION_MODES,
52 PERMISSION_MODE_INFO,
53 loadGlobalPermission,
54 saveGlobalPermission,
55 loadGlobalPermissionMode,
56 saveGlobalPermissionMode,
57 classifyCommand,
58 loadPermissionConfig,
59 savePermissionConfig,
60 invalidateConfigCache,
61 type PermissionConfig,
62} from "./permission-core.js";
63
64// Re-export types and constants needed by the hook
65export {
66 type PermissionLevel,
67 type PermissionMode,
68 LEVELS,
69 LEVEL_INFO,
70 PERMISSION_MODES,
71 PERMISSION_MODE_INFO,
72};
73
74// ============================================================================
75// SOUND NOTIFICATION
76// ============================================================================
77
78function playPermissionSound(): void {
79 const isMac = process.platform === "darwin";
80
81 if (isMac) {
82 exec('afplay /System/Library/Sounds/Funk.aiff 2>/dev/null', (err) => {
83 if (err) process.stdout.write("\x07");
84 });
85 } else {
86 process.stdout.write("\x07");
87 }
88}
89
90// ============================================================================
91// STATUS TEXT
92// ============================================================================
93
94const BOLD = "\x1b[1m";
95const RESET = "\x1b[0m";
96const RED = "\x1b[31m";
97const YELLOW = "\x1b[33m";
98const GREEN = "\x1b[32m";
99const CYAN = "\x1b[36m";
100const DIM = "\x1b[2m";
101
102const LEVEL_COLORS: Record<PermissionLevel, string> = {
103 minimal: RED,
104 low: YELLOW,
105 medium: CYAN,
106 high: GREEN,
107 bypassed: DIM,
108};
109
110function getStatusText(level: PermissionLevel): string {
111 const info = LEVEL_INFO[level];
112 const color = LEVEL_COLORS[level];
113 return `${BOLD}${color}${info.label}${RESET} ${DIM}- ${info.desc}${RESET}`;
114}
115
116// ============================================================================
117// MODE DETECTION
118// ============================================================================
119
120function getPiModeFromArgv(argv: string[] = process.argv): string | undefined {
121 // Support both: --mode rpc and --mode=rpc
122 const eq = argv.find((a) => a.startsWith("--mode="));
123 if (eq) return eq.slice("--mode=".length);
124
125 const idx = argv.indexOf("--mode");
126 if (idx !== -1 && idx + 1 < argv.length) return argv[idx + 1];
127
128 return undefined;
129}
130
131function hasInteractiveUI(ctx: any): boolean {
132 if (!ctx?.hasUI) return false;
133
134 // In non-interactive modes (rpc/json/print), UI prompts are not desired.
135 // We still allow notifications, but block instead of asking.
136 const mode = getPiModeFromArgv()?.toLowerCase();
137 if (mode && mode !== "interactive") return false;
138
139 return true;
140}
141
142function isQuietMode(ctx: any): boolean {
143 if (ctx?.quiet || ctx?.isQuiet) return true;
144 if (ctx?.ui?.quiet || ctx?.ui?.isQuiet) return true;
145 if (ctx?.settings?.quietStartup || ctx?.settings?.quiet) return true;
146
147 const envQuiet = process.env.PI_QUIET?.toLowerCase();
148 if (envQuiet && ["1", "true", "yes"].includes(envQuiet)) return true;
149
150 if (process.argv.includes("--quiet") || process.argv.includes("-q")) return true;
151
152 return isQuietStartupFromSettings();
153}
154
155function isQuietStartupFromSettings(): boolean {
156 const settingsPath = path.join(os.homedir(), ".pi", "agent", "settings.json");
157 try {
158 const raw = fs.readFileSync(settingsPath, "utf-8");
159 const settings = JSON.parse(raw) as { quietStartup?: boolean };
160 return settings.quietStartup === true;
161 } catch {
162 return false;
163 }
164}
165
166// ============================================================================
167// STATE MANAGEMENT
168// ============================================================================
169
170export interface PermissionState {
171 currentLevel: PermissionLevel;
172 isSessionOnly: boolean;
173 permissionMode: PermissionMode;
174 isModeSessionOnly: boolean;
175}
176
177export function createInitialState(): PermissionState {
178 return {
179 currentLevel: "minimal",
180 isSessionOnly: false,
181 permissionMode: "ask",
182 isModeSessionOnly: false,
183 };
184}
185
186function setLevel(
187 state: PermissionState,
188 level: PermissionLevel,
189 saveGlobally: boolean,
190 ctx: any
191): void {
192 state.currentLevel = level;
193 state.isSessionOnly = !saveGlobally;
194 if (saveGlobally) {
195 saveGlobalPermission(level);
196 }
197 if (ctx.ui?.setStatus) {
198 ctx.ui.setStatus("authority", getStatusText(level));
199 }
200}
201
202function setMode(
203 state: PermissionState,
204 mode: PermissionMode,
205 saveGlobally: boolean,
206 ctx: any
207): void {
208 state.permissionMode = mode;
209 state.isModeSessionOnly = !saveGlobally;
210 if (saveGlobally) {
211 saveGlobalPermissionMode(mode);
212 }
213}
214
215// ============================================================================
216// HANDLERS
217// ============================================================================
218
219/** Handle /permission config subcommand */
220async function handleConfigSubcommand(
221 state: PermissionState,
222 args: string,
223 ctx: any
224): Promise<void> {
225 const parts = args.trim().split(/\s+/);
226 const action = parts[0];
227
228 if (action === "show") {
229 const config = loadPermissionConfig();
230 const configStr = JSON.stringify(config, null, 2);
231 ctx.ui.notify(`Permission Config:\n${configStr}`, "info");
232 return;
233 }
234
235 if (action === "reset") {
236 savePermissionConfig({});
237 invalidateConfigCache();
238 ctx.ui.notify("Permission config reset to defaults", "info");
239 return;
240 }
241
242 // Show help
243 const help = `Usage: /permission config <action>
244
245Actions:
246 show - Display current configuration
247 reset - Reset to default configuration
248
249Edit ~/.pi/agent/settings.json directly for full control:
250
251{
252 "permissionConfig": {
253 "overrides": {
254 "minimal": ["tmux list-*", "tmux show-*"],
255 "medium": ["tmux *", "screen *"],
256 "high": ["rm -rf *"],
257 "dangerous": ["dd if=* of=/dev/*"]
258 },
259 "prefixMappings": [
260 { "from": "fvm flutter", "to": "flutter" },
261 { "from": "nvm exec", "to": "" }
262 ]
263 }
264}`;
265
266 ctx.ui.notify(help, "info");
267}
268
269/** Handle /permission command */
270export async function handlePermissionCommand(
271 state: PermissionState,
272 args: string,
273 ctx: any
274): Promise<void> {
275 const arg = args.trim().toLowerCase();
276
277 // Handle config subcommand
278 if (arg === "config" || arg.startsWith("config ")) {
279 const configArgs = arg.replace(/^config\s*/, '');
280 await handleConfigSubcommand(state, configArgs, ctx);
281 return;
282 }
283
284 // Direct level set: /permission medium
285 if (arg && LEVELS.includes(arg as PermissionLevel)) {
286 const newLevel = arg as PermissionLevel;
287
288 if (hasInteractiveUI(ctx)) {
289 const scope = await ctx.ui.select("Save permission level to:", [
290 "Session only",
291 "Global (persists)",
292 ]);
293 if (!scope) return;
294
295 setLevel(state, newLevel, scope === "Global (persists)", ctx);
296 const saveMsg = scope === "Global (persists)" ? " (saved globally)" : " (session only)";
297 ctx.ui.notify(`Permission: ${LEVEL_INFO[newLevel].label}${saveMsg}`, "info");
298 } else {
299 setLevel(state, newLevel, false, ctx);
300 ctx.ui.notify(`Permission: ${LEVEL_INFO[newLevel].label}`, "info");
301 }
302 return;
303 }
304
305 // Show current level (no UI)
306 if (!hasInteractiveUI(ctx)) {
307 ctx.ui.notify(
308 `Current permission: ${LEVEL_INFO[state.currentLevel].label} (${LEVEL_INFO[state.currentLevel].desc})`,
309 "info"
310 );
311 return;
312 }
313
314 // Show selector
315 const options = LEVELS.map((level) => {
316 const info = LEVEL_INFO[level];
317 const marker = level === state.currentLevel ? " ← current" : "";
318 return `${info.label}: ${info.desc}${marker}`;
319 });
320
321 const choice = await ctx.ui.select("Select permission level", options);
322 if (!choice) return;
323
324 const selectedLabel = choice.split(":")[0].trim();
325 const newLevel = LEVELS.find((l) => LEVEL_INFO[l].label === selectedLabel);
326 if (!newLevel || newLevel === state.currentLevel) return;
327
328 const scope = await ctx.ui.select("Save to:", ["Session only", "Global (persists)"]);
329 if (!scope) return;
330
331 setLevel(state, newLevel, scope === "Global (persists)", ctx);
332 const saveMsg = scope === "Global (persists)" ? " (saved globally)" : " (session only)";
333 ctx.ui.notify(`Permission: ${LEVEL_INFO[newLevel].label}${saveMsg}`, "info");
334}
335
336/** Handle /permission-mode command */
337export async function handlePermissionModeCommand(
338 state: PermissionState,
339 args: string,
340 ctx: any
341): Promise<void> {
342 const arg = args.trim().toLowerCase();
343
344 if (arg && PERMISSION_MODES.includes(arg as PermissionMode)) {
345 const newMode = arg as PermissionMode;
346
347 if (hasInteractiveUI(ctx)) {
348 const scope = await ctx.ui.select("Save permission mode to:", [
349 "Session only",
350 "Global (persists)",
351 ]);
352 if (!scope) return;
353
354 setMode(state, newMode, scope === "Global (persists)", ctx);
355 const saveMsg = scope === "Global (persists)" ? " (saved globally)" : " (session only)";
356 ctx.ui.notify(`Permission mode: ${PERMISSION_MODE_INFO[newMode].label}${saveMsg}`, "info");
357 } else {
358 setMode(state, newMode, false, ctx);
359 ctx.ui.notify(`Permission mode: ${PERMISSION_MODE_INFO[newMode].label}`, "info");
360 }
361 return;
362 }
363
364 if (!hasInteractiveUI(ctx)) {
365 ctx.ui.notify(
366 `Current permission mode: ${PERMISSION_MODE_INFO[state.permissionMode].label} (${PERMISSION_MODE_INFO[state.permissionMode].desc})`,
367 "info"
368 );
369 return;
370 }
371
372 const options = PERMISSION_MODES.map((mode) => {
373 const info = PERMISSION_MODE_INFO[mode];
374 const marker = mode === state.permissionMode ? " ← current" : "";
375 return `${info.label}: ${info.desc}${marker}`;
376 });
377
378 const choice = await ctx.ui.select("Select permission mode", options);
379 if (!choice) return;
380
381 const selectedLabel = choice.split(":")[0].trim();
382 const newMode = PERMISSION_MODES.find((m) => PERMISSION_MODE_INFO[m].label === selectedLabel);
383 if (!newMode || newMode === state.permissionMode) return;
384
385 const scope = await ctx.ui.select("Save to:", ["Session only", "Global (persists)"]);
386 if (!scope) return;
387
388 setMode(state, newMode, scope === "Global (persists)", ctx);
389 const saveMsg = scope === "Global (persists)" ? " (saved globally)" : " (session only)";
390 ctx.ui.notify(`Permission mode: ${PERMISSION_MODE_INFO[newMode].label}${saveMsg}`, "info");
391}
392
393/** Handle session_start - initialize level and show status */
394export function handleSessionStart(state: PermissionState, ctx: any): void {
395 // Check env var first (for print mode)
396 const envLevel = process.env.PI_PERMISSION_LEVEL?.toLowerCase();
397 if (envLevel && LEVELS.includes(envLevel as PermissionLevel)) {
398 state.currentLevel = envLevel as PermissionLevel;
399 } else {
400 const globalLevel = loadGlobalPermission();
401 if (globalLevel) {
402 state.currentLevel = globalLevel;
403 }
404 }
405
406 if (ctx.hasUI) {
407 const globalMode = loadGlobalPermissionMode();
408 if (globalMode) {
409 state.permissionMode = globalMode;
410 }
411 }
412
413 if (ctx.hasUI) {
414 if (ctx.ui?.setStatus) {
415 ctx.ui.setStatus("authority", getStatusText(state.currentLevel));
416 }
417 if (state.currentLevel === "bypassed") {
418 ctx.ui.notify("⚠️ Permission bypassed - all checks disabled!", "warning");
419 } else if (!isQuietMode(ctx)) {
420 ctx.ui.notify(`Permission: ${LEVEL_INFO[state.currentLevel].label} (use /permission to change)`, "info");
421 }
422 if (state.permissionMode === "block") {
423 ctx.ui.notify("Permission mode: Block (use /permission-mode to change)", "info");
424 }
425 }
426}
427
428/** Handle bash tool_call - check permission and prompt if needed */
429export async function handleBashToolCall(
430 state: PermissionState,
431 command: string,
432 ctx: any
433): Promise<{ block: true; reason: string } | undefined> {
434 if (state.currentLevel === "bypassed") return undefined;
435
436 const classification = classifyCommand(command);
437
438 // Dangerous commands - always prompt unless in block mode
439 if (classification.dangerous) {
440 if (!hasInteractiveUI(ctx)) {
441 return {
442 block: true,
443 reason: `Dangerous command requires confirmation: ${command}
444User can re-run with: PI_PERMISSION_LEVEL=bypassed pi -p "..."`
445 };
446 }
447
448 if (state.permissionMode === "block") {
449 return {
450 block: true,
451 reason: `Blocked by permission mode (block). Dangerous command: ${command}
452Use /permission-mode ask to enable confirmations.`
453 };
454 }
455
456 playPermissionSound();
457 const choice = await ctx.ui.select(
458 `⚠️ Dangerous command`,
459 ["Allow once", "Cancel"]
460 );
461
462 if (choice !== "Allow once") {
463 return { block: true, reason: "Cancelled" };
464 }
465 return undefined;
466 }
467
468 // Check level
469 const requiredIndex = LEVEL_INDEX[classification.level];
470 const currentIndex = LEVEL_INDEX[state.currentLevel];
471
472 if (requiredIndex <= currentIndex) return undefined;
473
474 const requiredLevel = classification.level;
475 const requiredInfo = LEVEL_INFO[requiredLevel];
476
477 // Print mode: block
478 if (!hasInteractiveUI(ctx)) {
479 return {
480 block: true,
481 reason: `Blocked by permission (${state.currentLevel}). Command: ${command}
482Allowed at this level: ${LEVEL_ALLOWED_DESC[state.currentLevel]}
483User can re-run with: PI_PERMISSION_LEVEL=${requiredLevel} pi -p "..."`
484 };
485 }
486
487 if (state.permissionMode === "block") {
488 return {
489 block: true,
490 reason: `Blocked by permission (${state.currentLevel}, mode: block). Command: ${command}
491Requires ${requiredInfo.label}. Allowed at this level: ${LEVEL_ALLOWED_DESC[state.currentLevel]}
492Use /permission ${requiredLevel} or /permission-mode ask to enable prompts.`
493 };
494 }
495
496 // Interactive mode: prompt
497 playPermissionSound();
498 const choice = await ctx.ui.select(
499 `Requires ${requiredInfo.label}`,
500 ["Allow once", `Allow all (${requiredInfo.label})`, "Cancel"]
501 );
502
503 if (choice === "Allow once") return undefined;
504
505 if (choice === `Allow all (${requiredInfo.label})`) {
506 setLevel(state, requiredLevel, true, ctx);
507 ctx.ui.notify(`Permission → ${requiredInfo.label} (saved globally)`, "info");
508 return undefined;
509 }
510
511 return { block: true, reason: "Cancelled" };
512}
513
514/** Options for handleWriteToolCall */
515export interface WriteToolCallOptions {
516 state: PermissionState;
517 toolName: string;
518 filePath: string;
519 ctx: any;
520}
521
522/** Handle write/edit tool_call - check permission and prompt if needed */
523export async function handleWriteToolCall(
524 opts: WriteToolCallOptions
525): Promise<{ block: true; reason: string } | undefined> {
526 const { state, toolName, filePath, ctx } = opts;
527
528 if (state.currentLevel === "bypassed") return undefined;
529
530 if (LEVEL_INDEX[state.currentLevel] >= LEVEL_INDEX["low"]) return undefined;
531
532 const action = toolName === "write" ? "Write" : "Edit";
533 const message = `Requires Low: ${action} ${filePath}`;
534
535 // Print mode: block
536 if (!hasInteractiveUI(ctx)) {
537 return {
538 block: true,
539 reason: `Blocked by permission (${state.currentLevel}). ${action}: ${filePath}
540Allowed at this level: ${LEVEL_ALLOWED_DESC[state.currentLevel]}
541User can re-run with: PI_PERMISSION_LEVEL=low pi -p "..."`
542 };
543 }
544
545 if (state.permissionMode === "block") {
546 return {
547 block: true,
548 reason: `Blocked by permission (${state.currentLevel}, mode: block). ${action}: ${filePath}
549Requires Low. Allowed at this level: ${LEVEL_ALLOWED_DESC[state.currentLevel]}
550Use /permission low or /permission-mode ask to enable prompts.`
551 };
552 }
553
554 // Interactive mode: prompt
555 playPermissionSound();
556 const choice = await ctx.ui.select(
557 message,
558 ["Allow once", "Allow all (Low)", "Cancel"]
559 );
560
561 if (choice === "Allow once") return undefined;
562
563 if (choice === "Allow all (Low)") {
564 setLevel(state, "low", true, ctx);
565 ctx.ui.notify(`Permission → Low (saved globally)`, "info");
566 return undefined;
567 }
568
569 return { block: true, reason: "Cancelled" };
570}
571
572// ============================================================================
573// Extension entry point
574// ============================================================================
575
576export default function (pi: ExtensionAPI) {
577 const state = createInitialState();
578
579 pi.registerCommand("permission", {
580 description: "View or change permission level",
581 handler: (args, ctx) => handlePermissionCommand(state, args, ctx),
582 });
583
584 pi.registerCommand("permission-mode", {
585 description: "Set permission prompt mode (ask or block)",
586 handler: (args, ctx) => handlePermissionModeCommand(state, args, ctx),
587 });
588
589 pi.on("session_start", async (_event, ctx) => {
590 handleSessionStart(state, ctx);
591 });
592
593 pi.on("tool_call", async (event, ctx) => {
594 if (event.toolName === "bash") {
595 return handleBashToolCall(state, event.input.command as string, ctx);
596 }
597
598 if (event.toolName === "write" || event.toolName === "edit") {
599 return handleWriteToolCall({
600 state,
601 toolName: event.toolName,
602 filePath: event.input.path as string,
603 ctx,
604 });
605 }
606
607 return undefined;
608 });
609}