Class: Clacky::Tools::CommandSafetyReplacer
- Inherits:
-
Object
- Object
- Clacky::Tools::CommandSafetyReplacer
- Defined in:
- lib/clacky/tools/safe_shell.rb
Instance Method Summary collapse
- #allow_dev_null_redirect(command) ⇒ Object
- #block_sudo_command(command) ⇒ Object
- #create_delete_metadata(original_path, trash_path) ⇒ Object
-
#initialize(project_root) ⇒ CommandSafetyReplacer
constructor
A new instance of CommandSafetyReplacer.
- #log_blocked_operation(operation, reason) ⇒ Object
- #log_replacement(original, replacement, reason) ⇒ Object
- #log_warning(message) ⇒ Object
- #make_command_safe(command) ⇒ Object
- #parse_rm_files(command) ⇒ Object
- #replace_chmod_command(command) ⇒ Object
- #replace_curl_pipe_command(command) ⇒ Object
- #replace_rm_command(command) ⇒ Object
-
#setup_safety_dirs ⇒ Object
setup_safety_dirs is now handled by TrashDirectory class Keep this method for backward compatibility but it does nothing.
- #validate_and_allow(command) ⇒ Object
- #validate_directory_creation(path) ⇒ Object
- #validate_file_path(path) ⇒ Object
- #validate_general_command(command) ⇒ Object
- #write_log(log_entry) ⇒ Object
Constructor Details
#initialize(project_root) ⇒ CommandSafetyReplacer
Returns a new instance of CommandSafetyReplacer.
317 318 319 320 321 322 323 324 325 326 327 328 329 330 |
# File 'lib/clacky/tools/safe_shell.rb', line 317 def initialize(project_root) @project_root = File.(project_root) # Use global trash directory organized by project trash_directory = Clacky::TrashDirectory.new(@project_root) @trash_dir = trash_directory.trash_dir @backup_dir = trash_directory.backup_dir # Setup safety log directory under ~/.clacky/safety_logs/ @project_hash = trash_directory.generate_project_hash(@project_root) @safety_log_dir = File.join(Dir.home, ".clacky", "safety_logs", @project_hash) FileUtils.mkdir_p(@safety_log_dir) unless Dir.exist?(@safety_log_dir) @safety_log_file = File.join(@safety_log_dir, "safety.log") end |
Instance Method Details
#allow_dev_null_redirect(command) ⇒ Object
428 429 430 431 |
# File 'lib/clacky/tools/safe_shell.rb', line 428 def allow_dev_null_redirect(command) # Allow output redirection to /dev/null, this is usually safe command end |
#block_sudo_command(command) ⇒ Object
424 425 426 |
# File 'lib/clacky/tools/safe_shell.rb', line 424 def block_sudo_command(command) raise SecurityError, "sudo commands are not allowed for security reasons" end |
#create_delete_metadata(original_path, trash_path) ⇒ Object
541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 |
# File 'lib/clacky/tools/safe_shell.rb', line 541 def (original_path, trash_path) = { original_path: File.(original_path), project_root: @project_root, trash_directory: File.dirname(trash_path), deleted_at: Time.now.iso8601, deleted_by: 'AI_SafeShell', file_size: File.size(original_path), file_type: File.extname(original_path), file_mode: File.stat(original_path).mode.to_s(8) } = "#{trash_path}.metadata.json" File.write(, JSON.pretty_generate()) rescue StandardError => e # If metadata creation fails, log warning but don't block operation log_warning("Failed to create metadata for #{original_path}: #{e.}") end |
#log_blocked_operation(operation, reason) ⇒ Object
578 579 580 581 582 583 584 585 586 587 |
# File 'lib/clacky/tools/safe_shell.rb', line 578 def log_blocked_operation(operation, reason) log_entry = { timestamp: Time.now.iso8601, action: 'operation_blocked', blocked_operation: operation, reason: reason } write_log(log_entry) end |
#log_replacement(original, replacement, reason) ⇒ Object
566 567 568 569 570 571 572 573 574 575 576 |
# File 'lib/clacky/tools/safe_shell.rb', line 566 def log_replacement(original, replacement, reason) log_entry = { timestamp: Time.now.iso8601, action: 'command_replacement', original_command: original, safe_replacement: replacement, reason: reason } write_log(log_entry) end |
#log_warning(message) ⇒ Object
589 590 591 592 593 594 595 596 597 |
# File 'lib/clacky/tools/safe_shell.rb', line 589 def log_warning() log_entry = { timestamp: Time.now.iso8601, action: 'warning', message: } write_log(log_entry) end |
#make_command_safe(command) ⇒ Object
332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 |
# File 'lib/clacky/tools/safe_shell.rb', line 332 def make_command_safe(command) command = command.strip # Safety checks use a UTF-8–scrubbed copy of the command so that # non-UTF-8 bytes in filenames (e.g. GBK-encoded Chinese paths from # zip archives) don't cause Encoding::InvalidByteSequenceError when # Ruby's regex / String#gsub tries to process them. # The original `command` (with original bytes) is returned unchanged # so the shell receives the exact bytes needed to locate the file. @safe_check_command = Clacky::Utils::Encoding.safe_check(command) case @safe_check_command when /pkill.*clacky|killall.*clacky|kill\s+.*\bclacky\b/i raise SecurityError, "Killing the clacky server process is not allowed. To restart, use: kill -USR1 $CLACKY_MASTER_PID" when /clacky\s+server/ raise SecurityError, "Managing the clacky server from within a session is not allowed. To restart, use: kill -USR1 $CLACKY_MASTER_PID" when /^rm\s+/ replace_rm_command(command) when /^chmod\s+x/ replace_chmod_command(command) when /^curl.*\|\s*(sh|bash)/ replace_curl_pipe_command(command) when /^sudo\s+/ block_sudo_command(command) when />\s*\/dev\/null\s*$/ allow_dev_null_redirect(command) when /^(mv|cp|mkdir|touch|echo)\s+/ validate_and_allow(command) else validate_general_command(@safe_check_command) command # validation passed, return original command with original bytes end end |
#parse_rm_files(command) ⇒ Object
490 491 492 493 494 495 496 497 498 499 500 |
# File 'lib/clacky/tools/safe_shell.rb', line 490 def parse_rm_files(command) begin parts = Shellwords.split(command) rescue ArgumentError => e # If Shellwords.split fails, use simple split as fallback parts = command.split(/\s+/) end # Skip rm command itself and option parameters parts.drop(1).reject { |part| part.start_with?('-') } end |
#replace_chmod_command(command) ⇒ Object
391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 |
# File 'lib/clacky/tools/safe_shell.rb', line 391 def replace_chmod_command(command) # Parse chmod command to ensure it's safe begin parts = Shellwords.split(command) rescue ArgumentError => e # If Shellwords.split fails, use simple split as fallback parts = command.split(/\s+/) end # Only allow chmod +x on files in project directory files = parts[2..-1] || [] files.each { |file| validate_file_path(file) unless file.start_with?('-') } # Allow chmod +x as it's generally safe log_replacement("chmod", command, "chmod +x is allowed - file permissions will be modified") command end |
#replace_curl_pipe_command(command) ⇒ Object
409 410 411 412 413 414 415 416 417 418 419 420 421 422 |
# File 'lib/clacky/tools/safe_shell.rb', line 409 def replace_curl_pipe_command(command) if command.match(/curl\s+(.*?)\s*\|\s*(sh|bash)/) url = $1 shell_type = $2 = Time.now.strftime("%Y%m%d_%H%M%S") safe_file = File.join(@backup_dir, "downloaded_script_#{}.sh") result = "curl #{url} -o #{Shellwords.escape(safe_file)} && echo '🔒 Script downloaded to #{safe_file} for manual review. Run: cat #{safe_file}'" log_replacement("curl | #{shell_type}", result, "Script saved for manual review instead of automatic execution") result else command end end |
#replace_rm_command(command) ⇒ Object
366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 |
# File 'lib/clacky/tools/safe_shell.rb', line 366 def replace_rm_command(command) files = parse_rm_files(command) if files.empty? raise SecurityError, "No files specified for deletion" end commands = files.map do |file| validate_file_path(file) = Time.now.strftime("%Y%m%d_%H%M%S_%N") safe_name = "#{File.basename(file)}_deleted_#{}" trash_path = File.join(@trash_dir, safe_name) # Create deletion metadata (file, trash_path) if File.exist?(file) "mv #{Shellwords.escape(file)} #{Shellwords.escape(trash_path)}" end result = commands.join(' && ') log_replacement("rm", result, "Files moved to trash instead of permanent deletion") result end |
#setup_safety_dirs ⇒ Object
setup_safety_dirs is now handled by TrashDirectory class Keep this method for backward compatibility but it does nothing
562 563 564 |
# File 'lib/clacky/tools/safe_shell.rb', line 562 def setup_safety_dirs # Directories are now setup by TrashDirectory class end |
#validate_and_allow(command) ⇒ Object
433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 |
# File 'lib/clacky/tools/safe_shell.rb', line 433 def validate_and_allow(command) # Check basic file operation commands begin parts = Shellwords.split(command) rescue ArgumentError => e # If Shellwords.split fails due to quote issues, try simple split as fallback # This handles cases where paths don't actually need shell escaping parts = command.split(/\s+/) end cmd = parts.first args = parts[1..-1] case cmd when 'mv', 'cp' # Ensure target paths are within project args.each { |path| validate_file_path(path) unless path.start_with?('-') } when 'mkdir' # Check directory creation permissions args.each { |path| validate_directory_creation(path) unless path.start_with?('-') } end command end |
#validate_directory_creation(path) ⇒ Object
533 534 535 536 537 538 539 |
# File 'lib/clacky/tools/safe_shell.rb', line 533 def validate_directory_creation(path) = File.(path) unless .start_with?(@project_root) raise SecurityError, "Directory creation outside project blocked: #{path}" end end |
#validate_file_path(path) ⇒ Object
502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 |
# File 'lib/clacky/tools/safe_shell.rb', line 502 def validate_file_path(path) return if path.start_with?('-') # Skip option parameters = File.(path) # Ensure file is within project directory unless .start_with?(@project_root) raise SecurityError, "File access outside project directory blocked: #{path}" end # Protect important files protected_patterns = [ /Gemfile$/, /Gemfile\.lock$/, /README\.md$/, /LICENSE/, /\.gitignore$/, /package\.json$/, /yarn\.lock$/, /\.env$/, /\.ssh\//, /\.aws\// ] protected_patterns.each do |pattern| if .match?(pattern) raise SecurityError, "Access to protected file blocked: #{File.basename(path)}" end end end |
#validate_general_command(command) ⇒ Object
458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 |
# File 'lib/clacky/tools/safe_shell.rb', line 458 def validate_general_command(command) # Check general command security. # NOTE: `command` here is always a valid UTF-8 string (scrubbed before # calling this method), so gsub / match will not raise encoding errors. # Note: We need to be careful not to match patterns inside quoted strings # First, remove quoted strings to avoid false positives # This is a simplified approach - removes both single and double quoted content cmd_without_quotes = command.gsub(/'[^']*'|"[^"]*"/, '') dangerous_patterns = [ /eval\s*\(/, /exec\s*\(/, /system\s*\(/, /`[^`]+`/, # Command substitution with backticks (but only if not in quotes) /\$\([^)]+\)/, # Command substitution with $() (but only if not in quotes) /\|\s*sh\s*$/, /\|\s*bash\s*$/, />\s*\/etc\//, />\s*\/usr\//, />\s*\/bin\// ] dangerous_patterns.each do |pattern| if cmd_without_quotes.match?(pattern) raise SecurityError, "Dangerous command pattern detected: #{pattern.source}" end end command end |
#write_log(log_entry) ⇒ Object
599 600 601 602 603 604 605 |
# File 'lib/clacky/tools/safe_shell.rb', line 599 def write_log(log_entry) File.open(@safety_log_file, 'a') do |f| f.puts JSON.generate(log_entry) end rescue StandardError # If log writing fails, silently ignore, don't affect main functionality end |