me like nix
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
11import * as fs from "node:fs";
12import * as path from "node:path";
13import { parse } from "shell-quote";
14
15// ============================================================================
16// TYPES
17// ============================================================================
18
19export type PermissionLevel = "minimal" | "low" | "medium" | "high" | "bypassed";
20
21export type PermissionMode = "ask" | "block";
22
23export const LEVELS: PermissionLevel[] = ["minimal", "low", "medium", "high", "bypassed"];
24export const PERMISSION_MODES: PermissionMode[] = ["ask", "block"];
25
26export const LEVEL_INDEX: Record<PermissionLevel, number> = {
27 minimal: 0,
28 low: 1,
29 medium: 2,
30 high: 3,
31 bypassed: 4,
32};
33
34export 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
42export 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
47export 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
55export interface Classification {
56 level: PermissionLevel;
57 dangerous: boolean;
58}
59
60// ============================================================================
61// CONFIGURATION TYPES
62// ============================================================================
63
64export 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
84let configCache: PermissionConfig | null = null;
85let configCacheTime = 0;
86/** Cache TTL in milliseconds - balance between responsiveness and performance */
87const CONFIG_CACHE_TTL = 5000; // 5 seconds
88
89let regexCache: Map<string, RegExp> = new Map();
90/** Maximum cached regex patterns to prevent memory exhaustion */
91const MAX_REGEX_CACHE_SIZE = 500;
92
93function 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
102function 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
116export 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 */
125function 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 */
179function 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 */
205function 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 */
218function 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 */
258function 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
295function getSettingsPath(): string {
296 return path.join(process.env.HOME || "", ".pi", "agent", "settings.json");
297}
298
299function loadSettings(): Record<string, unknown> {
300 try {
301 return JSON.parse(fs.readFileSync(getSettingsPath(), "utf-8"));
302 } catch {
303 return {};
304 }
305}
306
307function 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
330export 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
339export function saveGlobalPermission(level: PermissionLevel): void {
340 const settings = loadSettings();
341 settings.permissionLevel = level;
342 saveSettings(settings);
343}
344
345export 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
354export function saveGlobalPermissionMode(mode: PermissionMode): void {
355 const settings = loadSettings();
356 settings.permissionMode = mode;
357 saveSettings(settings);
358}
359
360export function loadPermissionConfig(): PermissionConfig {
361 const settings = loadSettings();
362 return validateConfig(settings.permissionConfig);
363}
364
365export function savePermissionConfig(config: PermissionConfig): void {
366 const settings = loadSettings();
367 settings.permissionConfig = config;
368 saveSettings(settings);
369}
370
371// ============================================================================
372// COMMAND PARSING
373// ============================================================================
374
375interface 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
385const 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
397const 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
406function 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
417function 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 */
433function hasArithmeticExpansion(command: string): boolean {
434 return /\$\(\(/.test(command);
435}
436
437// Output redirection operators that write to files
438const OUTPUT_REDIRECTION_OPS = new Set([">", ">>", ">|", "&>", "&>>"]);
439
440// Safe redirection targets (not actual file writes)
441const SAFE_REDIRECTION_TARGETS = new Set([
442 "/dev/null", "/dev/stdout", "/dev/stderr",
443 "/dev/fd/1", "/dev/fd/2",
444]);
445
446function 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
551function 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
573function 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)
628const 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)
635const FD_NUMBERS = new Set(["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]);
636
637// MINIMAL level - read-only commands
638const 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 */
668function 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
736const 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
764const 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
771const 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
787function 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)
844const 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
995const 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)
1005const 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
1014const UNSAFE_RUN_SCRIPTS = new Set([
1015 "start", "dev", "develop", "serve", "server", "watch", "preview",
1016 "start:dev", "start:prod", "dev:server",
1017]);
1018
1019function 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
1039function 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
1073function 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
1110function 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
1143export 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}