me like nix
0

Configure Feed

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

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}