Class: Clacky::Tools::SafeShell

Inherits:
Shell show all
Defined in:
lib/clacky/tools/safe_shell.rb

Constant Summary collapse

SAFE_READONLY_COMMANDS =

Safe read-only commands that don’t modify system state

%w[
  ls pwd cat less more head tail
  grep find which whereis whoami
  ps top htop df du
  git echo printf wc
  date file stat
  env printenv
  curl wget
].freeze

Constants inherited from Shell

Clacky::Tools::Shell::INTERACTION_PATTERNS, Clacky::Tools::Shell::MAX_LINE_CHARS, Clacky::Tools::Shell::MAX_LLM_OUTPUT_CHARS, Clacky::Tools::Shell::SLOW_COMMANDS

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from Shell

#detect_interaction, #detect_sudo_waiting, #determine_timeouts, #extract_command_name, #format_timeout_result, #format_waiting_input_result, #format_waiting_message, #output_truncated?, #truncate_and_save, #truncate_long_lines, #truncate_output, #wrap_with_shell

Methods inherited from Base

#category, #description, #name, #parameters, #to_function_definition

Class Method Details

.command_safe_for_auto_execution?(command) ⇒ Boolean

Class method to check if a command is safe to execute automatically

Returns:

  • (Boolean)


147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/clacky/tools/safe_shell.rb', line 147

def self.command_safe_for_auto_execution?(command)
  return false unless command

  # Check if it's a known safe read-only command
  cmd_name = command.strip.split.first
  return true if SAFE_READONLY_COMMANDS.include?(cmd_name)

  begin
    project_root = Dir.pwd
    safety_replacer = CommandSafetyReplacer.new(project_root)
    safe_command = safety_replacer.make_command_safe(command)

    # If the command wasn't changed by the safety replacer, it's considered safe
    # This means it doesn't need any modifications to be secure
    command.strip == safe_command.strip
  rescue SecurityError
    # If SecurityError is raised, the command is definitely not safe
    false
  end
end

Instance Method Details

#enhance_result(result, original_command, safe_command, safety_replacer = nil) ⇒ Object



168
169
170
171
172
173
174
175
176
177
178
179
180
181
# File 'lib/clacky/tools/safe_shell.rb', line 168

def enhance_result(result, original_command, safe_command, safety_replacer = nil)
  # If command was replaced, add security information
  if safety_replacer && original_command != safe_command
    result[:security_enhanced] = true
    result[:original_command] = original_command
    result[:safe_command] = safe_command

    # Add security note to stdout
    security_note = "[Safe] Command was automatically made safe\n"
    result[:stdout] = security_note + (result[:stdout] || "")
  end

  result
end

#execute(command:, timeout: nil, max_output_lines: 1000, skip_safety_check: false, on_output: nil, working_dir: nil) ⇒ Object



36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# File 'lib/clacky/tools/safe_shell.rb', line 36

def execute(command:, timeout: nil, max_output_lines: 1000, skip_safety_check: false, on_output: nil, working_dir: nil)
  # Use provided working_dir or fall back to current process directory
  project_root = working_dir || Dir.pwd

  begin
    # 1. Extract timeout from command if it starts with "timeout N"
    command, extracted_timeout = extract_timeout_from_command(command)

    # Use extracted timeout if not explicitly provided
    timeout ||= extracted_timeout

    # 2. Use safety replacer to process command (skip if user already confirmed)
    if skip_safety_check
      # User has confirmed, execute command as-is (no safety modifications)
      safe_command = command
      safety_replacer = nil
    else
      safety_replacer = CommandSafetyReplacer.new(project_root)
      safe_command = safety_replacer.make_command_safe(command)
    end

    # 3. Calculate timeouts: soft_timeout is fixed at 5s, hard_timeout from timeout parameter
    soft_timeout = 5
    hard_timeout = calculate_hard_timeout(command, timeout)

    # 4. Call parent class execution method
    result = super(command: safe_command, soft_timeout: soft_timeout, hard_timeout: hard_timeout, max_output_lines: max_output_lines, on_output: on_output, working_dir: working_dir)

    # 4a. If macOS xcode-select shim detected, replace stderr with actionable message
    if xcode_tools_missing?(result[:stderr])
      result = result.merge(
        stderr: "Xcode Command Line Tools are not installed.\nRun: bash ~/.clacky/scripts/install_system_deps.sh\nThen retry the original command.",
        exit_code: 1,
        success: false
      )
    end

    # 5. Enhance result information
    enhance_result(result, command, safe_command, safety_replacer)

  rescue SecurityError => e
    # Security error, return friendly error message
    {
      command: command,
      stdout: "",
      stderr: "[Security Protection] #{e.message}",
      exit_code: 126,
      success: false,
      security_blocked: true
    }
  end
end

#format_call(args) ⇒ Object



183
184
185
186
187
188
189
190
191
192
193
# File 'lib/clacky/tools/safe_shell.rb', line 183

def format_call(args)
  cmd = args[:command] || args['command'] || ''
  return "safe_shell(<no command>)" if cmd.empty?

  # Truncate long commands intelligently
  if cmd.length > 150
    "safe_shell(\"#{cmd[0..147]}...\")"
  else
    "safe_shell(\"#{cmd}\")"
  end
end

#format_result(result) ⇒ Object



195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
# File 'lib/clacky/tools/safe_shell.rb', line 195

def format_result(result)
  exit_code = result[:exit_code] || result['exit_code'] || 0
  stdout = result[:stdout] || result['stdout'] || ""
  stderr = result[:stderr] || result['stderr'] || ""

  if result[:security_blocked]
    "[Blocked] Security protection"
  elsif result[:security_enhanced]
    lines = stdout.lines.size
    "[Safe] Completed#{lines > 0 ? " (#{lines} lines)" : ''}"
  elsif exit_code == 0
    lines = stdout.lines.size
    "[OK] Completed#{lines > 0 ? " (#{lines} lines)" : ''}"
  else
    format_non_zero_exit(exit_code, stdout, stderr)
  end
end

#format_result_for_llm(result) ⇒ Object

Override format_result_for_llm to preserve security fields



214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
# File 'lib/clacky/tools/safe_shell.rb', line 214

def format_result_for_llm(result)
  # If security blocked, return as-is (small and important)
  return result if result[:security_blocked]
  
  # Call parent's format_result_for_llm to truncate output
  compact = super(result)
  
  # Add security enhancement fields if present (they're small and important for LLM to understand)
  if result[:security_enhanced]
    compact[:security_enhanced] = true
    compact[:original_command] = result[:original_command]
    compact[:safe_command] = result[:safe_command]
  end
  
  compact
end

#xcode_tools_missing?(stderr) ⇒ Boolean

Returns true if stderr contains the macOS xcode-select shim message, which appears when Xcode Command Line Tools are not installed and the user (or LLM) tries to run python3, git, make, gcc, etc.

Returns:

  • (Boolean)


310
311
312
313
# File 'lib/clacky/tools/safe_shell.rb', line 310

def xcode_tools_missing?(stderr)
  return false if stderr.nil? || stderr.empty?
  stderr.include?("xcode-select") && stderr.include?("No developer tools were found")
end