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.
@@ -0,0 +1,1194 @@
1
+ /**
2
+ * Core permission logic - command classification and settings
3
+ *
4
+ * This module contains pure functions for:
5
+ * - Parsing shell commands
6
+ * - Classifying commands by required permission level
7
+ * - Detecting dangerous commands
8
+ * - Managing settings persistence
9
+ */
10
+
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import { parse } from "shell-quote";
14
+
15
+ // ============================================================================
16
+ // TYPES
17
+ // ============================================================================
18
+
19
+ export type PermissionLevel = "minimal" | "low" | "medium" | "high" | "bypassed";
20
+
21
+ export type PermissionMode = "ask" | "block";
22
+
23
+ export const LEVELS: PermissionLevel[] = ["minimal", "low", "medium", "high", "bypassed"];
24
+ export const PERMISSION_MODES: PermissionMode[] = ["ask", "block"];
25
+
26
+ export const LEVEL_INDEX: Record<PermissionLevel, number> = {
27
+ minimal: 0,
28
+ low: 1,
29
+ medium: 2,
30
+ high: 3,
31
+ bypassed: 4,
32
+ };
33
+
34
+ export const LEVEL_INFO: Record<PermissionLevel, { label: string; desc: string }> = {
35
+ minimal: { label: "Minimal", desc: "Read-only" },
36
+ low: { label: "Low", desc: "File ops only" },
37
+ medium: { label: "Medium", desc: "Dev operations" },
38
+ high: { label: "High", desc: "Full operations" },
39
+ bypassed: { label: "Bypassed", desc: "All checks disabled" },
40
+ };
41
+
42
+ export const PERMISSION_MODE_INFO: Record<PermissionMode, { label: string; desc: string }> = {
43
+ ask: { label: "Ask", desc: "Prompt when permission is required" },
44
+ block: { label: "Block", desc: "Block instead of prompting" },
45
+ };
46
+
47
+ export const LEVEL_ALLOWED_DESC: Record<PermissionLevel, string> = {
48
+ minimal: "read-only (cat, ls, grep, git status/diff/log, npm list, version checks)",
49
+ low: "read-only + file write/edit",
50
+ medium: "dev ops (install packages, build, test, git commit/pull, file operations)",
51
+ high: "full operations except dangerous commands",
52
+ bypassed: "all operations",
53
+ };
54
+
55
+ export interface Classification {
56
+ level: PermissionLevel;
57
+ dangerous: boolean;
58
+ }
59
+
60
+ // ============================================================================
61
+ // CONFIGURATION TYPES
62
+ // ============================================================================
63
+
64
+ export interface PermissionConfig {
65
+ /** Override patterns to force specific permission levels */
66
+ overrides?: {
67
+ minimal?: string[];
68
+ low?: string[];
69
+ medium?: string[];
70
+ high?: string[];
71
+ dangerous?: string[];
72
+ };
73
+ /** Prefix mappings to normalize commands before classification */
74
+ prefixMappings?: Array<{
75
+ from: string;
76
+ to: string;
77
+ }>;
78
+ }
79
+
80
+ // ============================================================================
81
+ // CONFIGURATION CACHING
82
+ // ============================================================================
83
+
84
+ let configCache: PermissionConfig | null = null;
85
+ let configCacheTime = 0;
86
+ /** Cache TTL in milliseconds - balance between responsiveness and performance */
87
+ const CONFIG_CACHE_TTL = 5000; // 5 seconds
88
+
89
+ let regexCache: Map<string, RegExp> = new Map();
90
+ /** Maximum cached regex patterns to prevent memory exhaustion */
91
+ const MAX_REGEX_CACHE_SIZE = 500;
92
+
93
+ function getCachedConfig(): PermissionConfig {
94
+ const now = Date.now();
95
+ if (!configCache || now - configCacheTime > CONFIG_CACHE_TTL) {
96
+ configCache = loadPermissionConfig();
97
+ configCacheTime = now;
98
+ }
99
+ return configCache;
100
+ }
101
+
102
+ function getCachedRegex(pattern: string): RegExp {
103
+ let regex = regexCache.get(pattern);
104
+ if (!regex) {
105
+ // Evict oldest entries if cache is full (simple FIFO eviction)
106
+ if (regexCache.size >= MAX_REGEX_CACHE_SIZE) {
107
+ const firstKey = regexCache.keys().next().value;
108
+ if (firstKey) regexCache.delete(firstKey);
109
+ }
110
+ regex = globToRegex(pattern);
111
+ regexCache.set(pattern, regex);
112
+ }
113
+ return regex;
114
+ }
115
+
116
+ export function invalidateConfigCache(): void {
117
+ configCache = null;
118
+ regexCache.clear();
119
+ }
120
+
121
+ /**
122
+ * Validate and sanitize permission config
123
+ * Returns a safe config object with invalid entries removed
124
+ */
125
+ function validateConfig(config: unknown): PermissionConfig {
126
+ if (!config || typeof config !== 'object') {
127
+ return {};
128
+ }
129
+
130
+ const result: PermissionConfig = {};
131
+ const raw = config as Record<string, unknown>;
132
+
133
+ // Validate overrides
134
+ if (raw.overrides && typeof raw.overrides === 'object') {
135
+ const overrides = raw.overrides as Record<string, unknown>;
136
+ result.overrides = {};
137
+
138
+ const levels = ['minimal', 'low', 'medium', 'high', 'dangerous'] as const;
139
+ for (const level of levels) {
140
+ const patterns = overrides[level];
141
+ if (Array.isArray(patterns)) {
142
+ // Filter to only valid string patterns, limit count
143
+ const validPatterns = patterns
144
+ .filter((p): p is string => typeof p === 'string' && p.length > 0)
145
+ .slice(0, 100); // Max 100 patterns per level
146
+ if (validPatterns.length > 0) {
147
+ result.overrides[level] = validPatterns;
148
+ }
149
+ }
150
+ }
151
+ }
152
+
153
+ // Validate prefix mappings
154
+ if (Array.isArray(raw.prefixMappings)) {
155
+ const validMappings = raw.prefixMappings
156
+ .filter((m): m is { from: string; to: string } =>
157
+ m && typeof m === 'object' &&
158
+ typeof (m as any).from === 'string' && (m as any).from.length > 0 &&
159
+ typeof (m as any).to === 'string'
160
+ )
161
+ .slice(0, 50); // Max 50 prefix mappings
162
+ if (validMappings.length > 0) {
163
+ result.prefixMappings = validMappings;
164
+ }
165
+ }
166
+
167
+ return result;
168
+ }
169
+
170
+ // ============================================================================
171
+ // PATTERN MATCHING
172
+ // ============================================================================
173
+
174
+ /**
175
+ * Convert a glob-like pattern to a RegExp
176
+ * Supports: * (any chars), ? (single char)
177
+ * Patterns are matched against the full command string
178
+ */
179
+ function globToRegex(pattern: string): RegExp {
180
+ try {
181
+ // Limit pattern complexity to prevent ReDoS
182
+ // Reject patterns with too many consecutive * (creates .*.*.*... patterns)
183
+ if (/\*{5,}/.test(pattern)) {
184
+ // More than 4 consecutive * - reject to prevent exponential backtracking
185
+ return /(?!)/;
186
+ }
187
+
188
+ // Escape regex special chars first (except * and ? which we handle specially)
189
+ // Note: - is not special outside character classes, so we don't need to escape it
190
+ let regex = pattern
191
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
192
+ .replace(/\*/g, '.*') // * -> match any characters
193
+ .replace(/\?/g, '.'); // ? -> match single character
194
+
195
+ return new RegExp(`^${regex}$`, 'i');
196
+ } catch {
197
+ // Return a pattern that never matches on invalid input
198
+ return /(?!)/;
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Check if a command matches any pattern in the list
204
+ */
205
+ function matchesAnyPattern(command: string, patterns: string[] | undefined | null): boolean {
206
+ if (!patterns || !Array.isArray(patterns) || patterns.length === 0) {
207
+ return false;
208
+ }
209
+ return patterns.some(pattern =>
210
+ typeof pattern === 'string' && getCachedRegex(pattern).test(command)
211
+ );
212
+ }
213
+
214
+ /**
215
+ * Apply prefix mappings to normalize command before classification
216
+ * e.g., "fvm flutter build" → "flutter build"
217
+ */
218
+ function applyPrefixMappings(
219
+ command: string,
220
+ mappings: PermissionConfig['prefixMappings']
221
+ ): string {
222
+ if (!mappings || !Array.isArray(mappings) || mappings.length === 0) return command;
223
+
224
+ const trimmed = command.trim();
225
+ const trimmedLower = trimmed.toLowerCase();
226
+
227
+ for (const mapping of mappings) {
228
+ // Validate mapping structure
229
+ if (!mapping || typeof mapping.from !== 'string' || typeof mapping.to !== 'string') {
230
+ continue;
231
+ }
232
+
233
+ const { from, to } = mapping;
234
+ const fromLower = from.toLowerCase();
235
+
236
+ if (trimmedLower.startsWith(fromLower)) {
237
+ // Check for word boundary (whitespace or end of string after prefix)
238
+ const afterPrefix = trimmed.substring(fromLower.length);
239
+ // Use regex to check for whitespace boundary (handles tabs, multiple spaces)
240
+ if (afterPrefix === '' || /^\s/.test(afterPrefix)) {
241
+ // Replace prefix with mapped value, preserve rest with trimmed leading space
242
+ const remainder = afterPrefix.replace(/^\s+/, '');
243
+ if (to === '') {
244
+ return remainder;
245
+ }
246
+ return remainder ? `${to} ${remainder}` : to;
247
+ }
248
+ }
249
+ }
250
+
251
+ return command;
252
+ }
253
+
254
+ /**
255
+ * Check if command matches any configured override
256
+ * Returns the override classification or null if no match
257
+ */
258
+ function checkOverrides(
259
+ command: string,
260
+ overrides: PermissionConfig['overrides']
261
+ ): Classification | null {
262
+ if (!overrides) return null;
263
+
264
+ const trimmed = command.trim();
265
+
266
+ // Check dangerous first (highest priority)
267
+ if (overrides.dangerous && matchesAnyPattern(trimmed, overrides.dangerous)) {
268
+ return { level: 'high', dangerous: true };
269
+ }
270
+
271
+ // Check levels in order of specificity (high to low)
272
+ if (overrides.high && matchesAnyPattern(trimmed, overrides.high)) {
273
+ return { level: 'high', dangerous: false };
274
+ }
275
+
276
+ if (overrides.medium && matchesAnyPattern(trimmed, overrides.medium)) {
277
+ return { level: 'medium', dangerous: false };
278
+ }
279
+
280
+ if (overrides.low && matchesAnyPattern(trimmed, overrides.low)) {
281
+ return { level: 'low', dangerous: false };
282
+ }
283
+
284
+ if (overrides.minimal && matchesAnyPattern(trimmed, overrides.minimal)) {
285
+ return { level: 'minimal', dangerous: false };
286
+ }
287
+
288
+ return null; // No override matched
289
+ }
290
+
291
+ // ============================================================================
292
+ // SETTINGS PERSISTENCE
293
+ // ============================================================================
294
+
295
+ function getSettingsPath(): string {
296
+ return path.join(process.env.HOME || "", ".pi", "agent", "settings.json");
297
+ }
298
+
299
+ function loadSettings(): Record<string, unknown> {
300
+ try {
301
+ return JSON.parse(fs.readFileSync(getSettingsPath(), "utf-8"));
302
+ } catch {
303
+ return {};
304
+ }
305
+ }
306
+
307
+ function saveSettings(settings: Record<string, unknown>): void {
308
+ const settingsPath = getSettingsPath();
309
+ const dir = path.dirname(settingsPath);
310
+ const tempPath = `${settingsPath}.tmp`;
311
+
312
+ try {
313
+ if (!fs.existsSync(dir)) {
314
+ fs.mkdirSync(dir, { recursive: true });
315
+ }
316
+ // Atomic write: write to temp file first, then rename
317
+ fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + "\n");
318
+ fs.renameSync(tempPath, settingsPath); // Atomic on POSIX systems
319
+ } catch (e) {
320
+ // Clean up temp file on error
321
+ try {
322
+ if (fs.existsSync(tempPath)) {
323
+ fs.unlinkSync(tempPath);
324
+ }
325
+ } catch {}
326
+ throw e;
327
+ }
328
+ }
329
+
330
+ export function loadGlobalPermission(): PermissionLevel | null {
331
+ const settings = loadSettings();
332
+ const level = (settings.permissionLevel as string)?.toLowerCase();
333
+ if (level && LEVELS.includes(level as PermissionLevel)) {
334
+ return level as PermissionLevel;
335
+ }
336
+ return null;
337
+ }
338
+
339
+ export function saveGlobalPermission(level: PermissionLevel): void {
340
+ const settings = loadSettings();
341
+ settings.permissionLevel = level;
342
+ saveSettings(settings);
343
+ }
344
+
345
+ export function loadGlobalPermissionMode(): PermissionMode | null {
346
+ const settings = loadSettings();
347
+ const mode = (settings.permissionMode as string)?.toLowerCase();
348
+ if (mode && PERMISSION_MODES.includes(mode as PermissionMode)) {
349
+ return mode as PermissionMode;
350
+ }
351
+ return null;
352
+ }
353
+
354
+ export function saveGlobalPermissionMode(mode: PermissionMode): void {
355
+ const settings = loadSettings();
356
+ settings.permissionMode = mode;
357
+ saveSettings(settings);
358
+ }
359
+
360
+ export function loadPermissionConfig(): PermissionConfig {
361
+ const settings = loadSettings();
362
+ return validateConfig(settings.permissionConfig);
363
+ }
364
+
365
+ export function savePermissionConfig(config: PermissionConfig): void {
366
+ const settings = loadSettings();
367
+ settings.permissionConfig = config;
368
+ saveSettings(settings);
369
+ }
370
+
371
+ // ============================================================================
372
+ // COMMAND PARSING
373
+ // ============================================================================
374
+
375
+ interface ParsedCommand {
376
+ segments: string[][]; // Commands split by operators
377
+ operators: string[]; // |, &&, ||, ;
378
+ raw: string;
379
+ hasShellTricks?: boolean;
380
+ /** Output redirections to non-special files (>, >>) */
381
+ writesFiles?: boolean;
382
+ }
383
+
384
+ // Shell execution commands that can run arbitrary code
385
+ const SHELL_EXECUTION_COMMANDS = new Set([
386
+ "eval", "exec", "source", ".", // shell builtins
387
+ "env", // can execute commands: env rm -rf /
388
+ "command", // bypasses aliases, can execute arbitrary commands
389
+ "builtin", // uses shell builtins directly
390
+ // Wrapper commands that can execute arbitrary commands
391
+ "time", "nice", "nohup", "timeout", "watch", "strace",
392
+ // Note: xargs is handled in CONDITIONAL_WRITE_COMMANDS with smart logic
393
+ ]);
394
+
395
+ // Patterns that indicate command substitution or shell tricks in raw command
396
+ // Only patterns that can actually execute arbitrary code
397
+ const SHELL_TRICK_PATTERNS = [
398
+ /\$\((?!\()[^)]+\)/, // $(command) - command substitution (exclude $(( for arithmetic)
399
+ /`[^`]+`/, // `command` - backtick substitution
400
+ /<\([^)]+\)/, // <(command) - process substitution (input)
401
+ />\([^)]+\)/, // >(command) - process substitution (output)
402
+ ];
403
+
404
+ // Check if ${...} contains nested command substitution
405
+ // Simple ${VAR} is safe, but ${VAR:-$(cmd)} or ${VAR:-`cmd`} is dangerous
406
+ function hasDangerousExpansion(command: string): boolean {
407
+ const braceExpansions = command.match(/\$\{[^}]+\}/g) || [];
408
+ for (const expansion of braceExpansions) {
409
+ // Check for nested $() or backticks inside ${...}
410
+ if (/\$\(|\`/.test(expansion)) {
411
+ return true;
412
+ }
413
+ }
414
+ return false;
415
+ }
416
+
417
+ function detectShellTricks(command: string): boolean {
418
+ // Check basic patterns first
419
+ if (SHELL_TRICK_PATTERNS.some(pattern => pattern.test(command))) {
420
+ return true;
421
+ }
422
+ // Check for dangerous ${...} expansions with nested command substitution
423
+ if (hasDangerousExpansion(command)) {
424
+ return true;
425
+ }
426
+ return false;
427
+ }
428
+
429
+ /**
430
+ * Check if a command contains arithmetic expansion $((..))
431
+ * Used to avoid false positives from shell-quote parsing
432
+ */
433
+ function hasArithmeticExpansion(command: string): boolean {
434
+ return /\$\(\(/.test(command);
435
+ }
436
+
437
+ // Output redirection operators that write to files
438
+ const OUTPUT_REDIRECTION_OPS = new Set([">", ">>", ">|", "&>", "&>>"]);
439
+
440
+ // Safe redirection targets (not actual file writes)
441
+ const SAFE_REDIRECTION_TARGETS = new Set([
442
+ "/dev/null", "/dev/stdout", "/dev/stderr",
443
+ "/dev/fd/1", "/dev/fd/2",
444
+ ]);
445
+
446
+ function parseCommand(command: string): ParsedCommand {
447
+ const hasShellTricks = detectShellTricks(command);
448
+
449
+ // shell-quote can throw on complex patterns it doesn't understand
450
+ // In that case, treat the command as having shell tricks (require high permission)
451
+ let tokens: ReturnType<typeof parse>;
452
+ try {
453
+ tokens = parse(command);
454
+ } catch {
455
+ // Parse failed - treat as dangerous
456
+ return {
457
+ segments: [],
458
+ operators: [],
459
+ raw: command,
460
+ hasShellTricks: true
461
+ };
462
+ }
463
+
464
+ const segments: string[][] = [];
465
+ const operators: string[] = [];
466
+ let currentSegment: string[] = [];
467
+ let foundCommandSubstitution = false;
468
+ let writesFiles = false;
469
+
470
+ // Redirection operators - these don't start new command segments
471
+ const REDIRECTION_OPS = new Set([">", "<", ">>", ">&", "<&", ">|", "<>", "&>", "&>>"]);
472
+ let pendingOutputRedirect = false;
473
+
474
+ for (let i = 0; i < tokens.length; i++) {
475
+ const token = tokens[i];
476
+
477
+ if (pendingOutputRedirect) {
478
+ // This token is a redirection target
479
+ pendingOutputRedirect = false;
480
+ if (typeof token === "string") {
481
+ // Check if this is writing to a real file (not /dev/null etc.)
482
+ if (!SAFE_REDIRECTION_TARGETS.has(token) && !token.startsWith("/dev/fd/")) {
483
+ writesFiles = true;
484
+ }
485
+ }
486
+ continue;
487
+ }
488
+
489
+ if (typeof token === "string") {
490
+ currentSegment.push(token);
491
+ } else if (token && typeof token === "object") {
492
+ if ("op" in token) {
493
+ const op = token.op as string;
494
+ if (REDIRECTION_OPS.has(op)) {
495
+ // Check if this is an output redirection
496
+ if (OUTPUT_REDIRECTION_OPS.has(op)) {
497
+ pendingOutputRedirect = true;
498
+ } else {
499
+ // Input redirection or fd duplication - skip next token
500
+ // For >&, <& we need to check if it's fd duplication (2>&1) or file redirect
501
+ if (op === ">&" || op === "<&") {
502
+ const nextToken = tokens[i + 1];
503
+ if (typeof nextToken === "string" && /^\d+$/.test(nextToken)) {
504
+ // fd duplication like 2>&1, skip it
505
+ i++;
506
+ } else {
507
+ // File redirect like >&file
508
+ pendingOutputRedirect = true;
509
+ }
510
+ }
511
+ }
512
+ } else {
513
+ // Only treat actual command separators as segment boundaries
514
+ // ( and ) are grouping/subshell/arithmetic operators, not separators
515
+ const COMMAND_SEPARATORS = new Set(["|", "&&", "||", ";", "&"]);
516
+ if (COMMAND_SEPARATORS.has(op)) {
517
+ if (currentSegment.length > 0) {
518
+ segments.push(currentSegment);
519
+ currentSegment = [];
520
+ }
521
+ operators.push(op);
522
+ }
523
+ // Ignore ( and ) - they don't create new command segments
524
+ }
525
+ } else if ("comment" in token) {
526
+ // Comment - ignore
527
+ } else {
528
+ // shell-quote returns special objects for:
529
+ // - { op: 'glob', pattern: '*.js' } - globs
530
+ // - { op: string } - operators
531
+ // Any other object type indicates shell parsing complexity
532
+ // that we should treat as potentially dangerous
533
+ foundCommandSubstitution = true;
534
+ }
535
+ }
536
+ }
537
+
538
+ if (currentSegment.length > 0) {
539
+ segments.push(currentSegment);
540
+ }
541
+
542
+ return {
543
+ segments,
544
+ operators,
545
+ raw: command,
546
+ hasShellTricks: hasShellTricks || foundCommandSubstitution,
547
+ writesFiles
548
+ };
549
+ }
550
+
551
+ function getCommandName(tokens: string[]): string {
552
+ if (tokens.length === 0) return "";
553
+
554
+ let cmd = tokens[0];
555
+
556
+ // Strip path prefix
557
+ if (cmd.includes("/")) {
558
+ cmd = cmd.split("/").pop() || cmd;
559
+ }
560
+
561
+ // Strip leading backslash (alias bypass)
562
+ if (cmd.startsWith("\\")) {
563
+ cmd = cmd.slice(1);
564
+ }
565
+
566
+ return cmd.toLowerCase();
567
+ }
568
+
569
+ // ============================================================================
570
+ // DANGEROUS COMMAND DETECTION
571
+ // ============================================================================
572
+
573
+ function isDangerousCommand(tokens: string[]): boolean {
574
+ if (tokens.length === 0) return false;
575
+
576
+ const cmd = getCommandName(tokens);
577
+ const args = tokens.slice(1);
578
+ const argsStr = args.join(" ");
579
+
580
+ // sudo - always dangerous
581
+ if (cmd === "sudo") return true;
582
+
583
+ // rm with recursive + force
584
+ if (cmd === "rm") {
585
+ let hasRecursive = false;
586
+ let hasForce = false;
587
+
588
+ for (const arg of args) {
589
+ if (arg === "--recursive") hasRecursive = true;
590
+ if (arg === "--force") hasForce = true;
591
+ if (arg.startsWith("-") && !arg.startsWith("--")) {
592
+ if (arg.includes("r") || arg.includes("R")) hasRecursive = true;
593
+ if (arg.includes("f")) hasForce = true;
594
+ }
595
+ }
596
+
597
+ if (hasRecursive && hasForce) return true;
598
+ }
599
+
600
+ // chmod 777 or a+rwx
601
+ if (cmd === "chmod") {
602
+ if (argsStr.includes("777") || argsStr.includes("a+rwx")) return true;
603
+ }
604
+
605
+ // dd to device
606
+ if (cmd === "dd") {
607
+ if (argsStr.match(/of=\/dev\//)) return true;
608
+ }
609
+
610
+ // Dangerous system commands
611
+ if (["fdisk", "parted", "format"].includes(cmd)) return true;
612
+ if (cmd.startsWith("mkfs")) return true; // mkfs, mkfs.ext4, mkfs.xfs, etc.
613
+
614
+ // Shutdown/reboot
615
+ if (["shutdown", "reboot", "halt", "poweroff", "init"].includes(cmd)) return true;
616
+
617
+ // Fork bomb pattern
618
+ if (tokens.join("").includes(":(){ :|:& };:")) return true;
619
+
620
+ return false;
621
+ }
622
+
623
+ // ============================================================================
624
+ // LEVEL CLASSIFICATION
625
+ // ============================================================================
626
+
627
+ // Common redirection targets (treated as read-only)
628
+ const REDIRECTION_TARGETS = new Set([
629
+ "/dev/null", "/dev/stdin", "/dev/stdout", "/dev/stderr",
630
+ "/dev/zero", "/dev/full", "/dev/random", "/dev/urandom",
631
+ "/dev/fd", "/dev/tty", "/dev/ptmx",
632
+ ]);
633
+
634
+ // File descriptor numbers used in redirections (e.g., 2>&1)
635
+ const FD_NUMBERS = new Set(["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]);
636
+
637
+ // MINIMAL level - read-only commands
638
+ const MINIMAL_COMMANDS = new Set([
639
+ // File reading
640
+ "cat", "less", "more", "head", "tail", "bat", "tac",
641
+ // Directory listing/navigation
642
+ "ls", "tree", "pwd", "dir", "vdir", "cd", "pushd", "popd", "dirs",
643
+ // Search (note: find handled specially due to -exec/-delete)
644
+ "grep", "egrep", "fgrep", "rg", "ag", "ack", "fd", "locate", "which", "whereis",
645
+ // Info
646
+ "echo", "printf", "whoami", "id", "date", "cal", "uname", "hostname", "uptime",
647
+ "type", "file", "stat", "wc", "du", "df", "free",
648
+ "ps", "top", "htop", "pgrep", "sleep",
649
+ // Man/help
650
+ "man", "help", "info",
651
+ // Pipeline utilities (note: xargs, tee handled specially - they can write/execute)
652
+ "sort", "uniq", "cut", "awk", "sed", "tr", "column", "paste", "join",
653
+ "comm", "diff", "cmp", "patch",
654
+ // Shell test commands (read-only conditionals)
655
+ "test", "[", "[[", "true", "false",
656
+ ]);
657
+
658
+ // Commands that can write files based on arguments
659
+ // find: -exec, -execdir, -ok, -okdir, -delete can modify filesystem
660
+ // xargs: executes commands with input as arguments (but safe if running read-only commands)
661
+ // tee: writes to files (but read-only when used with /dev/null or --)
662
+
663
+ /**
664
+ * Extract the command that xargs will execute.
665
+ * Parses xargs options to find the first non-option argument.
666
+ * Returns null if no command specified (xargs defaults to /bin/echo).
667
+ */
668
+ function extractXargsCommand(tokens: string[]): string | null {
669
+ const args = tokens.slice(1); // Skip 'xargs' itself
670
+
671
+ // xargs options that consume the next argument
672
+ const OPTIONS_WITH_ARG = new Set(["-I", "-d", "-E", "-L", "-n", "-P", "-s", "-a"]);
673
+
674
+ let i = 0;
675
+ while (i < args.length) {
676
+ const arg = args[i];
677
+
678
+ // End of options marker
679
+ if (arg === "--") {
680
+ i++;
681
+ break;
682
+ }
683
+
684
+ // Not an option - this is the command
685
+ if (!arg.startsWith("-")) {
686
+ break;
687
+ }
688
+
689
+ // Long options (--null, --max-args=5, etc.)
690
+ if (arg.startsWith("--")) {
691
+ // Long options either are flags or use = for values, so just skip
692
+ i++;
693
+ continue;
694
+ }
695
+
696
+ // Short option that takes a required argument
697
+ // Could be: -I {} (separate) or -I{} (attached)
698
+ const optLetter = arg.substring(0, 2); // e.g., "-I"
699
+ if (OPTIONS_WITH_ARG.has(optLetter)) {
700
+ if (arg.length > 2) {
701
+ // Argument attached: -I{} or -n10
702
+ i++;
703
+ } else {
704
+ // Argument is next token: -I {}
705
+ i += 2;
706
+ }
707
+ continue;
708
+ }
709
+
710
+ // -i and -e can have optional attached argument (deprecated forms)
711
+ // -i[replstr], -e[eof-str]
712
+ if (arg.startsWith("-i") || arg.startsWith("-e")) {
713
+ i++;
714
+ continue;
715
+ }
716
+
717
+ // Other short options are flags (can be combined): -0, -t, -p, -r, -x
718
+ // e.g., -0tr means -0 -t -r
719
+ i++;
720
+ }
721
+
722
+ // Return the command if found
723
+ if (i < args.length) {
724
+ const cmd = args[i];
725
+ // Strip path prefix (e.g., /usr/bin/cat -> cat)
726
+ if (cmd.includes("/")) {
727
+ return cmd.split("/").pop()?.toLowerCase() || null;
728
+ }
729
+ return cmd.toLowerCase();
730
+ }
731
+
732
+ // No command found - xargs defaults to /bin/echo (safe)
733
+ return null;
734
+ }
735
+
736
+ const CONDITIONAL_WRITE_COMMANDS: Record<string, (tokens: string[]) => boolean> = {
737
+ find: (tokens) => {
738
+ const dangerousFlags = ["-exec", "-execdir", "-ok", "-okdir", "-delete"];
739
+ return tokens.some(t => dangerousFlags.includes(t.toLowerCase()));
740
+ },
741
+ xargs: (tokens) => {
742
+ // xargs executes commands with input as arguments
743
+ // Safe if running a read-only command from MINIMAL_COMMANDS
744
+ const xargsCmd = extractXargsCommand(tokens);
745
+
746
+ // No command = defaults to /bin/echo (safe, just prints)
747
+ if (xargsCmd === null) return false;
748
+
749
+ // Check if the command xargs will run is read-only
750
+ if (MINIMAL_COMMANDS.has(xargsCmd)) return false;
751
+
752
+ // Unknown or non-minimal command - not safe
753
+ return true;
754
+ },
755
+ tee: (tokens) => {
756
+ // tee writes to files unless only used with /dev/null or --
757
+ const args = tokens.slice(1).filter(t => !t.startsWith("-"));
758
+ if (args.length === 0) return false; // tee with no file args writes to stdout only
759
+ // Check if all file args are /dev/null
760
+ return !args.every(a => a === "/dev/null");
761
+ },
762
+ };
763
+
764
+ const MINIMAL_GIT_SUBCOMMANDS = new Set([
765
+ "status", "log", "diff", "show", "branch", "remote", "tag",
766
+ "ls-files", "ls-tree", "cat-file", "rev-parse", "describe",
767
+ "shortlog", "blame", "annotate", "whatchanged", "reflog",
768
+ "fetch", // read-only: just downloads refs, doesn't change working tree
769
+ ]);
770
+
771
+ const MINIMAL_PACKAGE_SUBCOMMANDS: Record<string, Set<string>> = {
772
+ npm: new Set(["list", "ls", "info", "view", "outdated", "audit", "explain", "why", "search"]),
773
+ yarn: new Set(["list", "info", "why", "outdated", "audit"]),
774
+ pnpm: new Set(["list", "ls", "outdated", "audit", "why"]),
775
+ bun: new Set(["pm", "ls"]),
776
+ pip: new Set(["list", "show", "freeze", "check"]),
777
+ pip3: new Set(["list", "show", "freeze", "check"]),
778
+ cargo: new Set(["tree", "metadata", "search", "info"]),
779
+ go: new Set(["list", "version", "env"]),
780
+ gem: new Set(["list", "info", "search", "query"]),
781
+ composer: new Set(["show", "info", "search", "outdated", "audit"]),
782
+ dotnet: new Set(["list", "nuget"]),
783
+ flutter: new Set(["doctor", "devices", "config"]),
784
+ dart: new Set(["info"]),
785
+ };
786
+
787
+ function isMinimalLevel(tokens: string[]): boolean {
788
+ if (tokens.length === 0) return true;
789
+
790
+ const cmd = getCommandName(tokens);
791
+ const fullCmd = tokens[0]; // Keep full path for checking redirection targets
792
+ const subCmd = tokens.length > 1 ? tokens[1].toLowerCase() : "";
793
+
794
+ // Check if this is a file descriptor number from redirection parsing (e.g., "1" from 2>&1)
795
+ if (tokens.length === 1 && FD_NUMBERS.has(fullCmd)) return true;
796
+
797
+ // Check if this is a common redirection target (e.g., /dev/null)
798
+ if (REDIRECTION_TARGETS.has(fullCmd)) return true;
799
+
800
+ // Check conditional write commands (find with -exec, xargs, tee with files)
801
+ const conditionalCheck = CONDITIONAL_WRITE_COMMANDS[cmd];
802
+ if (conditionalCheck) {
803
+ // If the command would write/execute, it's not minimal level
804
+ if (conditionalCheck(tokens)) {
805
+ return false;
806
+ }
807
+ // Otherwise it's safe (e.g., find without -exec, tee to /dev/null)
808
+ return true;
809
+ }
810
+
811
+ // Basic read-only commands
812
+ if (MINIMAL_COMMANDS.has(cmd)) return true;
813
+
814
+ // Version checks
815
+ if (tokens.includes("--version") || tokens.includes("-v") || tokens.includes("-V")) {
816
+ return true;
817
+ }
818
+
819
+ // Git read operations
820
+ if (cmd === "git" && subCmd && MINIMAL_GIT_SUBCOMMANDS.has(subCmd)) {
821
+ // Some git commands are only read-only without additional args
822
+ // e.g., "git branch" lists branches (minimal), "git branch new" creates (medium)
823
+ // e.g., "git tag" lists tags (minimal), "git tag v1.0" creates (medium)
824
+ const READ_ONLY_WITHOUT_ARGS = new Set(["branch", "tag", "remote"]);
825
+ if (READ_ONLY_WITHOUT_ARGS.has(subCmd)) {
826
+ // Check if there are args beyond flags (starting with -)
827
+ const nonFlagArgs = tokens.slice(2).filter(t => !t.startsWith("-"));
828
+ if (nonFlagArgs.length > 0) {
829
+ return false; // Has args, not read-only
830
+ }
831
+ }
832
+ return true;
833
+ }
834
+
835
+ // Package manager read operations
836
+ if (MINIMAL_PACKAGE_SUBCOMMANDS[cmd]?.has(subCmd)) {
837
+ return true;
838
+ }
839
+
840
+ return false;
841
+ }
842
+
843
+ // MEDIUM level - build/install/test operations only (NOT running code)
844
+ const MEDIUM_PACKAGE_PATTERNS: Array<[string, RegExp]> = [
845
+ // Node.js - install, build, test only (NOT run/start/exec which execute arbitrary code)
846
+ ["npm", /^(install|ci|add|remove|uninstall|update|rebuild|dedupe|prune|link|pack|test|build)$/],
847
+ ["yarn", /^(install|add|remove|upgrade|import|link|pack|test|build)$/],
848
+ ["pnpm", /^(install|add|remove|update|link|pack|test|build)$/],
849
+ ["bun", /^(install|add|remove|update|link|test|build)$/],
850
+ // npx/bunx/pnpx run arbitrary packages - HIGH (not included here)
851
+
852
+ // Python - install/build only (NOT running scripts)
853
+ ["pip", /^install$/],
854
+ ["pip3", /^install$/],
855
+ ["pipenv", /^(install|update|sync|lock|uninstall)$/],
856
+ ["poetry", /^(install|add|remove|update|lock|build)$/],
857
+ ["conda", /^(install|update|remove|create)$/],
858
+ ["uv", /^(pip|sync|lock)$/],
859
+ // python/python3 run arbitrary code - HIGH (not included here)
860
+ ["pytest", /./], // test runner is safe
861
+
862
+ // Rust - build/test/lint only (NOT cargo run)
863
+ ["cargo", /^(install|add|remove|fetch|update|build|test|check|clippy|fmt|doc|bench|clean)$/],
864
+ ["rustfmt", /./],
865
+ // rustc compiles but doesn't run - medium
866
+ ["rustc", /./],
867
+
868
+ // Go - build/test only (NOT go run)
869
+ ["go", /^(get|mod|build|test|generate|fmt|vet|clean|install)$/],
870
+
871
+ // Ruby - install/build only
872
+ ["gem", /^install$/],
873
+ ["bundle", /^(install|update|add|remove|binstubs)$/],
874
+ ["bundler", /^(install|update|add|remove)$/],
875
+ // CocoaPods - dependency management only
876
+ ["pod", /^(install|update|repo)$/],
877
+ // rake/rails can run arbitrary code - HIGH (not included here)
878
+ ["rspec", /./], // test runner
879
+
880
+ // PHP - install only
881
+ ["composer", /^(install|require|remove|update|dump-autoload)$/],
882
+ // php runs code - HIGH (not included here)
883
+ ["phpunit", /./], // test runner
884
+
885
+ // Java/Kotlin - compile/test only (NOT run)
886
+ ["mvn", /^(install|compile|test|package|clean|dependency|verify)$/],
887
+ ["gradle", /^(build|test|clean|assemble|dependencies|check)$/],
888
+ // gradlew can run arbitrary tasks - HIGH (not included here)
889
+
890
+ // .NET - build/test only (NOT run/watch)
891
+ ["dotnet", /^(restore|add|build|test|clean|publish|pack|new)$/],
892
+ ["nuget", /^install$/],
893
+
894
+ // Dart/Flutter - build/test only (NOT run)
895
+ ["dart", /^(pub|compile|test|analyze|format|fix)$/],
896
+ ["flutter", /^(pub|build|test|analyze|clean|create|doctor)$/],
897
+ ["pub", /^(get|upgrade|downgrade|cache|deps)$/],
898
+
899
+ // Swift - build/test only (NOT run)
900
+ ["swift", /^(package|build|test)$/],
901
+ ["swiftc", /./],
902
+
903
+ // Elixir - build/test only (NOT run)
904
+ ["mix", /^(deps|compile|test|ecto|phx\.gen)$/],
905
+ // elixir runs code - HIGH (not included here)
906
+
907
+ // Haskell - build/test only (NOT run)
908
+ ["cabal", /^(install|build|test|update)$/],
909
+ ["stack", /^(install|build|test|setup)$/],
910
+ // ghc compiles but doesn't run - medium
911
+ ["ghc", /./],
912
+
913
+ // Others
914
+ ["nimble", /^install$/],
915
+ ["zig", /^(build|test|fetch)$/],
916
+ ["cmake", /./],
917
+ ["make", /./],
918
+ ["ninja", /./],
919
+ ["meson", /./],
920
+
921
+ // Linters/formatters - static analysis only (MEDIUM)
922
+ ["eslint", /./],
923
+ ["prettier", /./],
924
+ ["black", /./],
925
+ ["flake8", /./],
926
+ ["pylint", /./],
927
+ ["ruff", /./],
928
+ ["pyflakes", /./],
929
+ ["bandit", /./],
930
+ ["mypy", /./],
931
+ ["pyright", /./],
932
+ ["tsc", /./],
933
+ ["tslint", /./],
934
+ ["standard", /./],
935
+ ["xo", /./],
936
+ ["rubocop", /./],
937
+ ["standardrb", /./],
938
+ ["reek", /./],
939
+ ["brakeman", /./],
940
+ ["golangci-lint", /./],
941
+ ["gofmt", /./],
942
+ ["go vet", /./],
943
+ ["golint", /./],
944
+ ["staticcheck", /./],
945
+ ["errcheck", /./],
946
+ ["misspell", /./],
947
+ ["swiftlint", /./],
948
+ ["swiftformat", /./],
949
+ ["ktlint", /./],
950
+ ["detekt", /./],
951
+ ["dartanalyzer", /./], // dart analyze alternative name
952
+ ["dartfmt", /./],
953
+ ["clang-tidy", /./],
954
+ ["clang-format", /./],
955
+ ["cppcheck", /./],
956
+ ["checkstyle", /./],
957
+ ["pmd", /./],
958
+ ["spotbugs", /./],
959
+ ["sonarqube", /./],
960
+ ["phpcs", /./],
961
+ ["phpmd", /./],
962
+ ["phpstan", /./],
963
+ ["psalm", /./],
964
+ ["php-cs-fixer", /./],
965
+ ["luacheck", /./],
966
+ ["shellcheck", /./],
967
+ ["checkov", /./],
968
+ ["tflint", /./],
969
+ ["buf", /./], // protobuf linter
970
+ ["sqlfluff", /./],
971
+ ["yamllint", /./],
972
+ ["markdownlint", /./],
973
+ ["djlint", /./],
974
+ ["djhtml", /./],
975
+ ["commitlint", /./],
976
+
977
+ // Test runners
978
+ ["jest", /./],
979
+ ["mocha", /./],
980
+ ["vitest", /./],
981
+
982
+ // File ops
983
+ ["mkdir", /./],
984
+ ["touch", /./],
985
+ ["cp", /./],
986
+ ["mv", /./],
987
+ ["ln", /./],
988
+
989
+ // Database (local dev)
990
+ ["prisma", /^(generate|migrate|db|studio)$/],
991
+ ["sequelize", /^(db|migration)$/],
992
+ ["typeorm", /^(migration)$/],
993
+ ];
994
+
995
+ const MEDIUM_GIT_SUBCOMMANDS = new Set([
996
+ "add", "commit", "pull", "checkout", "switch", "branch",
997
+ "merge", "rebase", "cherry-pick", "stash", "revert", "tag",
998
+ "rm", "mv", "reset", "clone", // reset without --hard, clone is reversible
999
+ // NOT included (irreversible):
1000
+ // - clean: permanently deletes untracked files
1001
+ // - restore: can discard uncommitted changes permanently
1002
+ ]);
1003
+
1004
+ // Safe npm/yarn/pnpm/bun run scripts (build, test, lint - not dev, start, serve)
1005
+ const SAFE_RUN_SCRIPTS = new Set([
1006
+ "build", "compile", "test", "lint", "format", "fmt", "check", "typecheck",
1007
+ "type-check", "types", "validate", "verify", "prepare", "prepublish",
1008
+ "prepublishOnly", "prepack", "postpack", "clean", "lint:fix", "format:check",
1009
+ "build:prod", "build:dev", "build:production", "build:development",
1010
+ "test:unit", "test:integration", "test:e2e", "test:coverage",
1011
+ ]);
1012
+
1013
+ // Scripts that run servers or arbitrary code
1014
+ const UNSAFE_RUN_SCRIPTS = new Set([
1015
+ "start", "dev", "develop", "serve", "server", "watch", "preview",
1016
+ "start:dev", "start:prod", "dev:server",
1017
+ ]);
1018
+
1019
+ function isSafeRunScript(script: string): boolean {
1020
+ const s = script.toLowerCase();
1021
+ // Check explicit safe list
1022
+ if (SAFE_RUN_SCRIPTS.has(s)) return true;
1023
+ // Check if starts with safe prefix
1024
+ if (s.startsWith("build") || s.startsWith("test") || s.startsWith("lint") ||
1025
+ s.startsWith("format") || s.startsWith("check") || s.startsWith("type")) {
1026
+ return true;
1027
+ }
1028
+ // Check explicit unsafe list
1029
+ if (UNSAFE_RUN_SCRIPTS.has(s)) return false;
1030
+ // Check unsafe prefixes
1031
+ if (s.startsWith("start") || s.startsWith("dev") || s.startsWith("serve") ||
1032
+ s.startsWith("watch")) {
1033
+ return false;
1034
+ }
1035
+ // Default: unknown scripts are unsafe
1036
+ return false;
1037
+ }
1038
+
1039
+ function isMediumLevel(tokens: string[]): boolean {
1040
+ if (tokens.length === 0) return false;
1041
+
1042
+ const cmd = getCommandName(tokens);
1043
+ const subCmd = tokens.length > 1 ? tokens[1].toLowerCase() : "";
1044
+ const thirdArg = tokens.length > 2 ? tokens[2] : "";
1045
+
1046
+ // Git local operations (not push)
1047
+ if (cmd === "git") {
1048
+ if (subCmd === "push") return false; // push is HIGH
1049
+ if (subCmd === "reset" && tokens.includes("--hard")) return false; // hard reset is HIGH
1050
+ if (MEDIUM_GIT_SUBCOMMANDS.has(subCmd)) return true;
1051
+ }
1052
+
1053
+ // Handle npm/yarn/pnpm/bun run <script> specially
1054
+ if (["npm", "yarn", "pnpm", "bun"].includes(cmd) && subCmd === "run") {
1055
+ // Need a script name
1056
+ if (!thirdArg || thirdArg.startsWith("-")) return false;
1057
+ return isSafeRunScript(thirdArg);
1058
+ }
1059
+
1060
+ // Package managers and build tools
1061
+ for (const [pattern, subPattern] of MEDIUM_PACKAGE_PATTERNS) {
1062
+ if (cmd === pattern) {
1063
+ if (!subCmd || subPattern.test(subCmd)) {
1064
+ return true;
1065
+ }
1066
+ }
1067
+ }
1068
+
1069
+ return false;
1070
+ }
1071
+
1072
+ // HIGH level - git push, remote operations
1073
+ function isHighLevel(tokens: string[]): boolean {
1074
+ if (tokens.length === 0) return false;
1075
+
1076
+ const cmd = getCommandName(tokens);
1077
+ const subCmd = tokens.length > 1 ? tokens[1].toLowerCase() : "";
1078
+ const argsStr = tokens.slice(1).join(" ");
1079
+
1080
+ // Git push
1081
+ if (cmd === "git" && subCmd === "push") return true;
1082
+
1083
+ // Git reset --hard
1084
+ if (cmd === "git" && subCmd === "reset" && tokens.includes("--hard")) return true;
1085
+
1086
+ // curl/wget piped to shell (detected at pipeline level)
1087
+ if (cmd === "curl" || cmd === "wget") return true;
1088
+
1089
+ // Running remote scripts
1090
+ if (cmd === "bash" || cmd === "sh" || cmd === "zsh") {
1091
+ if (argsStr.includes("http://") || argsStr.includes("https://")) return true;
1092
+ }
1093
+
1094
+ // Docker operations
1095
+ if (cmd === "docker" && ["push", "login", "logout"].includes(subCmd)) return true;
1096
+
1097
+ // Deployment tools
1098
+ if (["kubectl", "helm", "terraform", "pulumi", "ansible"].includes(cmd)) return true;
1099
+
1100
+ // SSH/SCP
1101
+ if (["ssh", "scp", "rsync"].includes(cmd)) return true;
1102
+
1103
+ return false;
1104
+ }
1105
+
1106
+ // ============================================================================
1107
+ // CLASSIFY COMMAND
1108
+ // ============================================================================
1109
+
1110
+ function classifySegment(tokens: string[]): Classification {
1111
+ if (tokens.length === 0) {
1112
+ return { level: "minimal", dangerous: false };
1113
+ }
1114
+
1115
+ const cmd = getCommandName(tokens);
1116
+
1117
+ // Shell execution commands that can run arbitrary code - always HIGH
1118
+ // These bypass normal command classification since they execute their arguments
1119
+ if (SHELL_EXECUTION_COMMANDS.has(cmd)) {
1120
+ return { level: "high", dangerous: false };
1121
+ }
1122
+
1123
+ if (isDangerousCommand(tokens)) {
1124
+ return { level: "high", dangerous: true };
1125
+ }
1126
+
1127
+ if (isMinimalLevel(tokens)) {
1128
+ return { level: "minimal", dangerous: false };
1129
+ }
1130
+
1131
+ if (isMediumLevel(tokens)) {
1132
+ return { level: "medium", dangerous: false };
1133
+ }
1134
+
1135
+ if (isHighLevel(tokens)) {
1136
+ return { level: "high", dangerous: false };
1137
+ }
1138
+
1139
+ // Default: require HIGH for unknown commands
1140
+ return { level: "high", dangerous: false };
1141
+ }
1142
+
1143
+ export function classifyCommand(command: string, config?: PermissionConfig): Classification {
1144
+ // Load config if not provided (for testing)
1145
+ const effectiveConfig = config ?? getCachedConfig();
1146
+
1147
+ // Step 1: Apply prefix normalization
1148
+ const normalizedCommand = applyPrefixMappings(command, effectiveConfig.prefixMappings);
1149
+
1150
+ const parsed = parseCommand(normalizedCommand);
1151
+
1152
+ // If command contains shell tricks (command substitution, backticks, etc.),
1153
+ // require HIGH level as we cannot reliably classify the embedded commands
1154
+ if (parsed.hasShellTricks) {
1155
+ return { level: "high", dangerous: false };
1156
+ }
1157
+
1158
+ // Step 2: Check for override on NORMALIZED command (consistent with classification)
1159
+ const override = checkOverrides(normalizedCommand, effectiveConfig.overrides);
1160
+ if (override) {
1161
+ return override;
1162
+ }
1163
+
1164
+ let maxLevel: PermissionLevel = "minimal";
1165
+ let dangerous = false;
1166
+
1167
+ // If command writes to files via redirection (>, >>), require at least LOW
1168
+ if (parsed.writesFiles) {
1169
+ maxLevel = "low";
1170
+ }
1171
+
1172
+ for (let i = 0; i < parsed.segments.length; i++) {
1173
+ const segment = parsed.segments[i];
1174
+ const segmentClass = classifySegment(segment);
1175
+
1176
+ if (segmentClass.dangerous) {
1177
+ dangerous = true;
1178
+ }
1179
+
1180
+ if (LEVEL_INDEX[segmentClass.level] > LEVEL_INDEX[maxLevel]) {
1181
+ maxLevel = segmentClass.level;
1182
+ }
1183
+
1184
+ // Check for piping to shell
1185
+ if (i < parsed.segments.length - 1 && parsed.operators[i] === "|") {
1186
+ const nextCmd = getCommandName(parsed.segments[i + 1]);
1187
+ if (["bash", "sh", "zsh", "node", "python", "python3", "ruby", "perl"].includes(nextCmd)) {
1188
+ maxLevel = "high";
1189
+ }
1190
+ }
1191
+ }
1192
+
1193
+ return { level: maxLevel, dangerous };
1194
+ }