clacky 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,287 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "../utils/limit_stack"
5
+ require "yaml"
6
+ require "open3"
7
+
8
+ module Clacky
9
+ module Tools
10
+ class RunProject < Base
11
+ self.tool_name = "run_project"
12
+ self.tool_description = "Start, stop, or get status of the project dev server from .1024 config"
13
+ self.tool_category = "system"
14
+ self.tool_parameters = {
15
+ type: "object",
16
+ properties: {
17
+ action: {
18
+ type: "string",
19
+ enum: ["start", "stop", "status", "output"],
20
+ description: "Action to perform: start (launch dev server), stop (kill dev server), status (check if running), output (get recent logs)"
21
+ },
22
+ max_lines: {
23
+ type: "integer",
24
+ description: "For 'output' action: max lines of logs to return (default: 100)"
25
+ }
26
+ },
27
+ required: ["action"]
28
+ }
29
+
30
+ CONFIG_PATHS = ['.1024', '.clackyai/.environments.yaml'].freeze
31
+
32
+ @@process_state = nil
33
+ @@reader_thread = nil
34
+
35
+ def initialize
36
+ super
37
+ end
38
+
39
+ def execute(action:, max_lines: 100)
40
+ case action
41
+ when "start"
42
+ start_project
43
+ when "stop"
44
+ stop_project
45
+ when "status"
46
+ get_status
47
+ when "output"
48
+ get_output(max_lines)
49
+ else
50
+ { error: "Unknown action: #{action}" }
51
+ end
52
+ end
53
+
54
+ def format_call(args)
55
+ action = args[:action] || args['action']
56
+
57
+ case action
58
+ when 'start'
59
+ config = load_project_config
60
+ if config && (cmd = config['run_command'] || config['run_commands'])
61
+ cmd = cmd.join(' && ') if cmd.is_a?(Array)
62
+ cmd_preview = cmd.length > 40 ? "#{cmd[0..40]}..." : cmd
63
+ "RunProject(start: #{cmd_preview})"
64
+ else
65
+ "RunProject(start)"
66
+ end
67
+ when 'output'
68
+ max_lines = args[:max_lines] || args['max_lines'] || 100
69
+ "RunProject(output: #{max_lines} lines)"
70
+ else
71
+ "RunProject(#{action})"
72
+ end
73
+ end
74
+
75
+ def format_result(result)
76
+ if result[:error]
77
+ "✗ #{result[:error]}"
78
+ elsif result[:status]
79
+ case result[:status]
80
+ when 'started'
81
+ cmd_preview = result[:command] ? result[:command][0..50] : ''
82
+ output_preview = result[:output]&.lines&.first(2)&.join&.strip
83
+ msg = "✓ Started (PID: #{result[:pid]}, cmd: #{cmd_preview})"
84
+ msg += "\n #{output_preview}" if output_preview && !output_preview.empty?
85
+ msg
86
+ when 'stopped'
87
+ "✓ Stopped"
88
+ when 'running'
89
+ uptime = result[:uptime] ? "#{result[:uptime].round(1)}s" : "unknown"
90
+ "Running (#{uptime}, PID: #{result[:pid]})"
91
+ when 'not_running'
92
+ "Not running"
93
+ else
94
+ result[:status].to_s
95
+ end
96
+ else
97
+ "Done"
98
+ end
99
+ end
100
+
101
+ private
102
+
103
+ def start_project
104
+ config = load_project_config
105
+ return { error: "No .1024 config file found. Create .1024 with 'run_command: your_command'" } unless config
106
+
107
+ command = config['run_command'] || config['run_commands']
108
+ return { error: "No 'run_command' defined in .1024" } unless command
109
+
110
+ command = command.join(' && ') if command.is_a?(Array)
111
+
112
+ stop_existing_process if @@process_state
113
+
114
+ begin
115
+ stdin, stdout, stderr, wait_thr = Open3.popen3(command)
116
+
117
+ @@process_state = {
118
+ stdin: stdin,
119
+ stdout: stdout,
120
+ stderr: stderr,
121
+ thread: wait_thr,
122
+ start_time: Time.now,
123
+ command: command,
124
+ stdout_buffer: Utils::LimitStack.new(max_size: 5000),
125
+ stderr_buffer: Utils::LimitStack.new(max_size: 5000)
126
+ }
127
+
128
+ start_output_reader_thread
129
+
130
+ sleep 2
131
+
132
+ output = read_buffered_output(max_lines: 50)
133
+
134
+ {
135
+ status: 'started',
136
+ pid: wait_thr.pid,
137
+ command: command,
138
+ output: output,
139
+ message: "Project started in background. Use run_project(action: 'output') to check logs."
140
+ }
141
+ rescue StandardError => e
142
+ @@process_state = nil
143
+ {
144
+ error: "Failed to start project: #{e.message}",
145
+ command: command
146
+ }
147
+ end
148
+ end
149
+
150
+ def stop_project
151
+ return { status: 'not_running', message: 'No running process to stop' } unless @@process_state
152
+
153
+ thread = @@process_state[:thread]
154
+ pid = thread.pid
155
+
156
+ begin
157
+ if thread.alive?
158
+ Process.kill('INT', pid)
159
+ sleep 1
160
+
161
+ if thread.alive?
162
+ Process.kill('KILL', pid) rescue nil
163
+ end
164
+ end
165
+
166
+ @@reader_thread&.kill
167
+
168
+ @@process_state = nil
169
+ @@reader_thread = nil
170
+
171
+ {
172
+ status: 'stopped',
173
+ message: "Process #{pid} stopped successfully"
174
+ }
175
+ rescue StandardError => e
176
+ {
177
+ error: "Failed to stop process: #{e.message}"
178
+ }
179
+ end
180
+ end
181
+
182
+ def get_status
183
+ if @@process_state && @@process_state[:thread].alive?
184
+ {
185
+ status: 'running',
186
+ pid: @@process_state[:thread].pid,
187
+ uptime: Time.now - @@process_state[:start_time],
188
+ command: @@process_state[:command]
189
+ }
190
+ else
191
+ { status: 'not_running' }
192
+ end
193
+ end
194
+
195
+ def get_output(max_lines)
196
+ return { error: 'No running process' } unless @@process_state
197
+
198
+ output = read_buffered_output(max_lines: max_lines)
199
+
200
+ {
201
+ status: 'running',
202
+ pid: @@process_state[:thread].pid,
203
+ uptime: Time.now - @@process_state[:start_time],
204
+ output: output
205
+ }
206
+ end
207
+
208
+ def stop_existing_process
209
+ return unless @@process_state
210
+
211
+ thread = @@process_state[:thread]
212
+ if thread.alive?
213
+ Process.kill('INT', thread.pid) rescue nil
214
+ sleep 1
215
+
216
+ if thread.alive?
217
+ Process.kill('KILL', thread.pid) rescue nil
218
+ end
219
+ end
220
+
221
+ @@reader_thread&.kill
222
+ @@process_state = nil
223
+ @@reader_thread = nil
224
+ end
225
+
226
+ def load_project_config
227
+ CONFIG_PATHS.each do |path|
228
+ full_path = File.join(Dir.pwd, path)
229
+ next unless File.exist?(full_path)
230
+
231
+ begin
232
+ content = File.read(full_path)
233
+ return YAML.safe_load(content)
234
+ rescue StandardError => e
235
+ next
236
+ end
237
+ end
238
+
239
+ nil
240
+ end
241
+
242
+ def start_output_reader_thread
243
+ @@reader_thread = Thread.new do
244
+ loop do
245
+ break unless @@process_state
246
+
247
+ stdout = @@process_state[:stdout]
248
+ stderr = @@process_state[:stderr]
249
+ stdout_buf = @@process_state[:stdout_buffer]
250
+ stderr_buf = @@process_state[:stderr_buffer]
251
+
252
+ begin
253
+ ready = IO.select([stdout, stderr], nil, nil, 0.5)
254
+ if ready
255
+ ready[0].each do |io|
256
+ begin
257
+ data = io.read_nonblock(4096)
258
+ if io == stdout
259
+ stdout_buf.push_lines(data)
260
+ else
261
+ stderr_buf.push_lines(data)
262
+ end
263
+ rescue IO::WaitReadable, EOFError
264
+ end
265
+ end
266
+ end
267
+ rescue StandardError => e
268
+ end
269
+
270
+ sleep 0.1
271
+ end
272
+ end
273
+ end
274
+
275
+ def read_buffered_output(max_lines:)
276
+ return "" unless @@process_state
277
+
278
+ stdout_lines = @@process_state[:stdout_buffer].to_a
279
+ stderr_lines = @@process_state[:stderr_buffer].to_a
280
+
281
+ # Combine and get last N lines
282
+ all_lines = (stdout_lines + stderr_lines).last(max_lines)
283
+ all_lines.join
284
+ end
285
+ end
286
+ end
287
+ end
@@ -0,0 +1,397 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+ require "json"
5
+ require "fileutils"
6
+ require_relative "shell"
7
+
8
+ module Clacky
9
+ module Tools
10
+ class SafeShell < Shell
11
+ self.tool_name = "safe_shell"
12
+ self.tool_description = "Execute shell commands with enhanced security - dangerous commands are automatically made safe"
13
+ self.tool_category = "system"
14
+ self.tool_parameters = {
15
+ type: "object",
16
+ properties: {
17
+ command: {
18
+ type: "string",
19
+ description: "Shell command to execute"
20
+ },
21
+ soft_timeout: {
22
+ type: "integer",
23
+ description: "Soft timeout in seconds (for interaction detection)"
24
+ },
25
+ hard_timeout: {
26
+ type: "integer",
27
+ description: "Hard timeout in seconds (force kill)"
28
+ }
29
+ },
30
+ required: ["command"]
31
+ }
32
+
33
+ def execute(command:, soft_timeout: nil, hard_timeout: nil)
34
+ # Get project root directory
35
+ project_root = Dir.pwd
36
+
37
+ begin
38
+ # 1. Use safety replacer to process command
39
+ safety_replacer = CommandSafetyReplacer.new(project_root)
40
+ safe_command = safety_replacer.make_command_safe(command)
41
+
42
+ # 2. Call parent class execution method
43
+ result = super(command: safe_command, soft_timeout: soft_timeout, hard_timeout: hard_timeout)
44
+
45
+ # 3. Enhance result information
46
+ enhance_result(result, command, safe_command)
47
+
48
+ rescue SecurityError => e
49
+ # Security error, return friendly error message
50
+ {
51
+ command: command,
52
+ stdout: "",
53
+ stderr: "🔒 Security Protection: #{e.message}",
54
+ exit_code: 126,
55
+ success: false,
56
+ security_blocked: true
57
+ }
58
+ end
59
+ end
60
+
61
+ # Safe read-only commands that don't modify system state
62
+ SAFE_READONLY_COMMANDS = %w[
63
+ ls pwd cat less more head tail
64
+ grep find which whereis whoami
65
+ ps top htop df du
66
+ git echo printf wc
67
+ date file stat
68
+ env printenv
69
+ curl wget
70
+ ].freeze
71
+
72
+ # Class method to check if a command is safe to execute automatically
73
+ def self.command_safe_for_auto_execution?(command)
74
+ return false unless command
75
+
76
+ # Check if it's a known safe read-only command
77
+ cmd_name = command.strip.split.first
78
+ return true if SAFE_READONLY_COMMANDS.include?(cmd_name)
79
+
80
+ begin
81
+ project_root = Dir.pwd
82
+ safety_replacer = CommandSafetyReplacer.new(project_root)
83
+ safe_command = safety_replacer.make_command_safe(command)
84
+
85
+ # If the command wasn't changed by the safety replacer, it's considered safe
86
+ # This means it doesn't need any modifications to be secure
87
+ command.strip == safe_command.strip
88
+ rescue SecurityError
89
+ # If SecurityError is raised, the command is definitely not safe
90
+ false
91
+ end
92
+ end
93
+
94
+ def enhance_result(result, original_command, safe_command)
95
+ # If command was replaced, add security information
96
+ if original_command != safe_command
97
+ result[:security_enhanced] = true
98
+ result[:original_command] = original_command
99
+ result[:safe_command] = safe_command
100
+
101
+ # Add security note to stdout
102
+ security_note = "🔒 Command was automatically made safe\n"
103
+ result[:stdout] = security_note + (result[:stdout] || "")
104
+ end
105
+
106
+ result
107
+ end
108
+
109
+ def format_call(args)
110
+ cmd = args[:command] || args['command'] || ''
111
+ return "safe_shell(<no command>)" if cmd.empty?
112
+
113
+ # Truncate long commands intelligently
114
+ if cmd.length > 50
115
+ "safe_shell(\"#{cmd[0..47]}...\")"
116
+ else
117
+ "safe_shell(\"#{cmd}\")"
118
+ end
119
+ end
120
+
121
+ def format_result(result)
122
+ exit_code = result[:exit_code] || result['exit_code'] || 0
123
+ stdout = result[:stdout] || result['stdout'] || ""
124
+ stderr = result[:stderr] || result['stderr'] || ""
125
+
126
+ if result[:security_blocked]
127
+ "🔒 Blocked for security"
128
+ elsif result[:security_enhanced]
129
+ lines = stdout.lines.size
130
+ "🔒✓ Safe execution#{lines > 0 ? " (#{lines} lines)" : ''}"
131
+ elsif exit_code == 0
132
+ lines = stdout.lines.size
133
+ "✓ Completed#{lines > 0 ? " (#{lines} lines)" : ''}"
134
+ else
135
+ error_msg = stderr.lines.first&.strip || "Failed"
136
+ "✗ Exit #{exit_code}: #{error_msg[0..50]}"
137
+ end
138
+ end
139
+ end
140
+
141
+ class CommandSafetyReplacer
142
+ def initialize(project_root)
143
+ @project_root = File.expand_path(project_root)
144
+ @trash_dir = File.join(@project_root, '.ai_trash')
145
+ @backup_dir = File.join(@project_root, '.ai_backups')
146
+ setup_safety_dirs
147
+ end
148
+
149
+ def make_command_safe(command)
150
+ command = command.strip
151
+
152
+ case command
153
+ when /^rm\s+/
154
+ replace_rm_command(command)
155
+ when /^chmod\s+\+x/
156
+ replace_chmod_command(command)
157
+ when /^curl.*\|\s*(sh|bash)/
158
+ replace_curl_pipe_command(command)
159
+ when /^sudo\s+/
160
+ block_sudo_command(command)
161
+ when />\s*\/dev\/null\s*$/
162
+ allow_dev_null_redirect(command)
163
+ when /^(mv|cp|mkdir|touch|echo)\s+/
164
+ validate_and_allow(command)
165
+ else
166
+ validate_general_command(command)
167
+ end
168
+ end
169
+
170
+ def replace_rm_command(command)
171
+ files = parse_rm_files(command)
172
+
173
+ if files.empty?
174
+ raise SecurityError, "No files specified for deletion"
175
+ end
176
+
177
+ commands = files.map do |file|
178
+ validate_file_path(file)
179
+
180
+ timestamp = Time.now.strftime("%Y%m%d_%H%M%S_%N")
181
+ safe_name = "#{File.basename(file)}_deleted_#{timestamp}"
182
+ trash_path = File.join(@trash_dir, safe_name)
183
+
184
+ # Create deletion metadata
185
+ create_delete_metadata(file, trash_path) if File.exist?(file)
186
+
187
+ "mv #{Shellwords.escape(file)} #{Shellwords.escape(trash_path)}"
188
+ end
189
+
190
+ result = commands.join(' && ')
191
+ log_replacement("rm", result, "Files moved to trash instead of permanent deletion")
192
+ result
193
+ end
194
+
195
+ def replace_chmod_command(command)
196
+ # Parse chmod command to ensure it's safe
197
+ parts = Shellwords.split(command)
198
+
199
+ # Only allow chmod +x on files in project directory
200
+ files = parts[2..-1] || []
201
+ files.each { |file| validate_file_path(file) unless file.start_with?('-') }
202
+
203
+ # Allow chmod +x as it's generally safe
204
+ log_replacement("chmod", command, "chmod +x is allowed - file permissions will be modified")
205
+ command
206
+ rescue Shellwords::BadQuotedString
207
+ raise SecurityError, "Invalid chmod command syntax: #{command}"
208
+ end
209
+
210
+ def replace_curl_pipe_command(command)
211
+ if command.match(/curl\s+(.*?)\s*\|\s*(sh|bash)/)
212
+ url = $1
213
+ shell_type = $2
214
+ timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
215
+ safe_file = File.join(@backup_dir, "downloaded_script_#{timestamp}.sh")
216
+
217
+ result = "curl #{url} -o #{Shellwords.escape(safe_file)} && echo '🔒 Script downloaded to #{safe_file} for manual review. Run: cat #{safe_file}'"
218
+ log_replacement("curl | #{shell_type}", result, "Script saved for manual review instead of automatic execution")
219
+ result
220
+ else
221
+ command
222
+ end
223
+ end
224
+
225
+ def block_sudo_command(command)
226
+ raise SecurityError, "sudo commands are not allowed for security reasons"
227
+ end
228
+
229
+ def allow_dev_null_redirect(command)
230
+ # Allow output redirection to /dev/null, this is usually safe
231
+ command
232
+ end
233
+
234
+ def validate_and_allow(command)
235
+ # Check basic file operation commands
236
+ parts = Shellwords.split(command)
237
+ cmd = parts.first
238
+ args = parts[1..-1]
239
+
240
+ case cmd
241
+ when 'mv', 'cp'
242
+ # Ensure target paths are within project
243
+ args.each { |path| validate_file_path(path) unless path.start_with?('-') }
244
+ when 'mkdir'
245
+ # Check directory creation permissions
246
+ args.each { |path| validate_directory_creation(path) unless path.start_with?('-') }
247
+ end
248
+
249
+ command
250
+ rescue Shellwords::BadQuotedString
251
+ raise SecurityError, "Invalid command syntax: #{command}"
252
+ end
253
+
254
+ def validate_general_command(command)
255
+ # Check general command security
256
+ dangerous_patterns = [
257
+ /eval\s*\(/,
258
+ /exec\s*\(/,
259
+ /system\s*\(/,
260
+ /`.*`/,
261
+ /\$\(.*\)/,
262
+ /\|\s*sh\s*$/,
263
+ /\|\s*bash\s*$/,
264
+ />\s*\/etc\//,
265
+ />\s*\/usr\//,
266
+ />\s*\/bin\//
267
+ ]
268
+
269
+ dangerous_patterns.each do |pattern|
270
+ if command.match?(pattern)
271
+ raise SecurityError, "Dangerous command pattern detected: #{pattern.source}"
272
+ end
273
+ end
274
+
275
+ command
276
+ end
277
+
278
+ def parse_rm_files(command)
279
+ parts = Shellwords.split(command)
280
+ # Skip rm command itself and option parameters
281
+ parts.drop(1).reject { |part| part.start_with?('-') }
282
+ rescue Shellwords::BadQuotedString
283
+ raise SecurityError, "Invalid command syntax: #{command}"
284
+ end
285
+
286
+ def validate_file_path(path)
287
+ return if path.start_with?('-') # Skip option parameters
288
+
289
+ expanded_path = File.expand_path(path)
290
+
291
+ # Ensure file is within project directory
292
+ unless expanded_path.start_with?(@project_root)
293
+ raise SecurityError, "File access outside project directory blocked: #{path}"
294
+ end
295
+
296
+ # Protect important files
297
+ protected_patterns = [
298
+ /Gemfile$/,
299
+ /Gemfile\.lock$/,
300
+ /README\.md$/,
301
+ /LICENSE/,
302
+ /\.gitignore$/,
303
+ /package\.json$/,
304
+ /yarn\.lock$/,
305
+ /\.env$/,
306
+ /\.ssh\//,
307
+ /\.aws\//
308
+ ]
309
+
310
+ protected_patterns.each do |pattern|
311
+ if expanded_path.match?(pattern)
312
+ raise SecurityError, "Access to protected file blocked: #{File.basename(path)}"
313
+ end
314
+ end
315
+ end
316
+
317
+ def validate_directory_creation(path)
318
+ expanded_path = File.expand_path(path)
319
+
320
+ unless expanded_path.start_with?(@project_root)
321
+ raise SecurityError, "Directory creation outside project blocked: #{path}"
322
+ end
323
+ end
324
+
325
+ def create_delete_metadata(original_path, trash_path)
326
+ metadata = {
327
+ original_path: File.expand_path(original_path),
328
+ deleted_at: Time.now.iso8601,
329
+ deleted_by: 'AI_SafeShell',
330
+ file_size: File.size(original_path),
331
+ file_type: File.extname(original_path),
332
+ file_mode: File.stat(original_path).mode.to_s(8)
333
+ }
334
+
335
+ metadata_file = "#{trash_path}.metadata.json"
336
+ File.write(metadata_file, JSON.pretty_generate(metadata))
337
+ rescue StandardError => e
338
+ # If metadata creation fails, log warning but don't block operation
339
+ log_warning("Failed to create metadata for #{original_path}: #{e.message}")
340
+ end
341
+
342
+ def setup_safety_dirs
343
+ [@trash_dir, @backup_dir].each do |dir|
344
+ FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
345
+
346
+ # Create .gitignore file to avoid trash files being committed
347
+ gitignore_path = File.join(dir, '.gitignore')
348
+ unless File.exist?(gitignore_path)
349
+ File.write(gitignore_path, "*\n!.gitignore\n")
350
+ end
351
+ end
352
+ end
353
+
354
+ def log_replacement(original, replacement, reason)
355
+ log_entry = {
356
+ timestamp: Time.now.iso8601,
357
+ action: 'command_replacement',
358
+ original_command: original,
359
+ safe_replacement: replacement,
360
+ reason: reason
361
+ }
362
+
363
+ write_log(log_entry)
364
+ end
365
+
366
+ def log_blocked_operation(operation, reason)
367
+ log_entry = {
368
+ timestamp: Time.now.iso8601,
369
+ action: 'operation_blocked',
370
+ blocked_operation: operation,
371
+ reason: reason
372
+ }
373
+
374
+ write_log(log_entry)
375
+ end
376
+
377
+ def log_warning(message)
378
+ log_entry = {
379
+ timestamp: Time.now.iso8601,
380
+ action: 'warning',
381
+ message: message
382
+ }
383
+
384
+ write_log(log_entry)
385
+ end
386
+
387
+ def write_log(log_entry)
388
+ log_file = File.join(@project_root, '.ai_safety.log')
389
+ File.open(log_file, 'a') do |f|
390
+ f.puts JSON.generate(log_entry)
391
+ end
392
+ rescue StandardError
393
+ # If log writing fails, silently ignore, don't affect main functionality
394
+ end
395
+ end
396
+ end
397
+ end