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/README.md +277 -0
- package/package.json +29 -0
- package/permission-core.ts +1194 -0
- package/permission.ts +609 -0
- package/tests/permission.test.ts +1438 -0
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
|
+
}
|