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.
- checksums.yaml +7 -0
- data/.clackyrules +80 -0
- data/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +74 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +272 -0
- data/Rakefile +12 -0
- data/lib/clacky/agent.rb +964 -0
- data/lib/clacky/agent_config.rb +47 -0
- data/lib/clacky/cli.rb +666 -0
- data/lib/clacky/client.rb +159 -0
- data/lib/clacky/config.rb +43 -0
- data/lib/clacky/conversation.rb +41 -0
- data/lib/clacky/hook_manager.rb +61 -0
- data/lib/clacky/progress_indicator.rb +53 -0
- data/lib/clacky/session_manager.rb +124 -0
- data/lib/clacky/thinking_verbs.rb +26 -0
- data/lib/clacky/tool_registry.rb +44 -0
- data/lib/clacky/tools/base.rb +64 -0
- data/lib/clacky/tools/edit.rb +100 -0
- data/lib/clacky/tools/file_reader.rb +79 -0
- data/lib/clacky/tools/glob.rb +93 -0
- data/lib/clacky/tools/grep.rb +169 -0
- data/lib/clacky/tools/run_project.rb +287 -0
- data/lib/clacky/tools/safe_shell.rb +397 -0
- data/lib/clacky/tools/shell.rb +305 -0
- data/lib/clacky/tools/todo_manager.rb +228 -0
- data/lib/clacky/tools/trash_manager.rb +367 -0
- data/lib/clacky/tools/web_fetch.rb +161 -0
- data/lib/clacky/tools/web_search.rb +138 -0
- data/lib/clacky/tools/write.rb +65 -0
- data/lib/clacky/utils/arguments_parser.rb +139 -0
- data/lib/clacky/utils/limit_stack.rb +80 -0
- data/lib/clacky/utils/path_helper.rb +15 -0
- data/lib/clacky/version.rb +5 -0
- data/lib/clacky.rb +38 -0
- data/sig/clacky.rbs +4 -0
- metadata +152 -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,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
|