me like nix
0

Configure Feed

Select the types of activity you want to include in your feed.

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}