permission-pi 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/permission.ts ADDED
@@ -0,0 +1,609 @@
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
+
39
+ import { exec } from "node:child_process";
40
+ import fs from "node:fs";
41
+ import os from "node:os";
42
+ import path from "node:path";
43
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
44
+ import {
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
65
+ export {
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
+
78
+ function 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
+
94
+ const BOLD = "\x1b[1m";
95
+ const RESET = "\x1b[0m";
96
+ const RED = "\x1b[31m";
97
+ const YELLOW = "\x1b[33m";
98
+ const GREEN = "\x1b[32m";
99
+ const CYAN = "\x1b[36m";
100
+ const DIM = "\x1b[2m";
101
+
102
+ const LEVEL_COLORS: Record<PermissionLevel, string> = {
103
+ minimal: RED,
104
+ low: YELLOW,
105
+ medium: CYAN,
106
+ high: GREEN,
107
+ bypassed: DIM,
108
+ };
109
+
110
+ function 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
+
120
+ function 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
+
131
+ function 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
+
142
+ function 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
+
155
+ function 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
+
170
+ export interface PermissionState {
171
+ currentLevel: PermissionLevel;
172
+ isSessionOnly: boolean;
173
+ permissionMode: PermissionMode;
174
+ isModeSessionOnly: boolean;
175
+ }
176
+
177
+ export function createInitialState(): PermissionState {
178
+ return {
179
+ currentLevel: "minimal",
180
+ isSessionOnly: false,
181
+ permissionMode: "ask",
182
+ isModeSessionOnly: false,
183
+ };
184
+ }
185
+
186
+ function 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
+
202
+ function 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 */
220
+ async 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
+
245
+ Actions:
246
+ show - Display current configuration
247
+ reset - Reset to default configuration
248
+
249
+ Edit ~/.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 */
270
+ export 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 */
337
+ export 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 */
394
+ export 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 */
429
+ export 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}
444
+ User 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}
452
+ Use /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}
482
+ Allowed at this level: ${LEVEL_ALLOWED_DESC[state.currentLevel]}
483
+ User 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}
491
+ Requires ${requiredInfo.label}. Allowed at this level: ${LEVEL_ALLOWED_DESC[state.currentLevel]}
492
+ Use /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 */
515
+ export 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 */
523
+ export 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}
540
+ Allowed at this level: ${LEVEL_ALLOWED_DESC[state.currentLevel]}
541
+ User 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}
549
+ Requires Low. Allowed at this level: ${LEVEL_ALLOWED_DESC[state.currentLevel]}
550
+ Use /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
+
576
+ export 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
+ }