openclacky 0.5.1

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