rcrewai 0.1.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/CHANGELOG.md +108 -0
- data/LICENSE +21 -0
- data/README.md +328 -0
- data/Rakefile +130 -0
- data/bin/rcrewai +7 -0
- data/docs/_config.yml +59 -0
- data/docs/_layouts/api.html +16 -0
- data/docs/_layouts/default.html +78 -0
- data/docs/_layouts/example.html +24 -0
- data/docs/_layouts/tutorial.html +33 -0
- data/docs/api/configuration.md +327 -0
- data/docs/api/crew.md +345 -0
- data/docs/api/index.md +41 -0
- data/docs/api/tools.md +412 -0
- data/docs/assets/css/style.css +416 -0
- data/docs/examples/human-in-the-loop.md +382 -0
- data/docs/examples/index.md +78 -0
- data/docs/examples/production-ready-crew.md +485 -0
- data/docs/examples/simple-research-crew.md +297 -0
- data/docs/index.md +353 -0
- data/docs/tutorials/getting-started.md +341 -0
- data/examples/async_execution_example.rb +294 -0
- data/examples/hierarchical_crew_example.rb +193 -0
- data/examples/human_in_the_loop_example.rb +233 -0
- data/lib/rcrewai/agent.rb +636 -0
- data/lib/rcrewai/async_executor.rb +248 -0
- data/lib/rcrewai/cli.rb +39 -0
- data/lib/rcrewai/configuration.rb +100 -0
- data/lib/rcrewai/crew.rb +292 -0
- data/lib/rcrewai/human_input.rb +520 -0
- data/lib/rcrewai/llm_client.rb +41 -0
- data/lib/rcrewai/llm_clients/anthropic.rb +127 -0
- data/lib/rcrewai/llm_clients/azure.rb +158 -0
- data/lib/rcrewai/llm_clients/base.rb +82 -0
- data/lib/rcrewai/llm_clients/google.rb +158 -0
- data/lib/rcrewai/llm_clients/ollama.rb +199 -0
- data/lib/rcrewai/llm_clients/openai.rb +124 -0
- data/lib/rcrewai/memory.rb +194 -0
- data/lib/rcrewai/process.rb +421 -0
- data/lib/rcrewai/task.rb +376 -0
- data/lib/rcrewai/tools/base.rb +82 -0
- data/lib/rcrewai/tools/code_executor.rb +333 -0
- data/lib/rcrewai/tools/email_sender.rb +210 -0
- data/lib/rcrewai/tools/file_reader.rb +111 -0
- data/lib/rcrewai/tools/file_writer.rb +115 -0
- data/lib/rcrewai/tools/pdf_processor.rb +342 -0
- data/lib/rcrewai/tools/sql_database.rb +226 -0
- data/lib/rcrewai/tools/web_search.rb +131 -0
- data/lib/rcrewai/version.rb +5 -0
- data/lib/rcrewai.rb +36 -0
- data/rcrewai.gemspec +54 -0
- metadata +365 -0
@@ -0,0 +1,333 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
require 'open3'
|
5
|
+
require 'tempfile'
|
6
|
+
require 'fileutils'
|
7
|
+
|
8
|
+
module RCrewAI
|
9
|
+
module Tools
|
10
|
+
class CodeExecutor < Base
|
11
|
+
def initialize(**options)
|
12
|
+
super()
|
13
|
+
@name = 'codeexecutor'
|
14
|
+
@description = 'Execute code in various programming languages (Python, Ruby, JavaScript, etc.)'
|
15
|
+
@timeout = options.fetch(:timeout, 30)
|
16
|
+
@max_output_size = options.fetch(:max_output_size, 100_000) # 100KB
|
17
|
+
@allowed_languages = options.fetch(:allowed_languages, %w[python ruby javascript bash])
|
18
|
+
@working_directory = options[:working_directory] || Dir.mktmpdir('rcrewai_code_')
|
19
|
+
@enable_file_operations = options.fetch(:enable_file_operations, false)
|
20
|
+
setup_security_restrictions
|
21
|
+
end
|
22
|
+
|
23
|
+
def execute(**params)
|
24
|
+
validate_params!(params, required: [:code, :language], optional: [:args, :stdin])
|
25
|
+
|
26
|
+
language = params[:language].to_s.downcase
|
27
|
+
code = params[:code]
|
28
|
+
args = params[:args] || []
|
29
|
+
stdin_input = params[:stdin]
|
30
|
+
|
31
|
+
begin
|
32
|
+
validate_execution_params!(language, code)
|
33
|
+
result = execute_code(language, code, args, stdin_input)
|
34
|
+
format_execution_result(language, code, result)
|
35
|
+
rescue => e
|
36
|
+
"Code execution failed: #{e.message}"
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def setup_security_restrictions
|
43
|
+
# Create secure working directory
|
44
|
+
FileUtils.mkdir_p(@working_directory)
|
45
|
+
File.chmod(0o755, @working_directory)
|
46
|
+
|
47
|
+
# Set environment variables for security
|
48
|
+
@secure_env = {
|
49
|
+
'PATH' => '/usr/local/bin:/usr/bin:/bin',
|
50
|
+
'HOME' => @working_directory,
|
51
|
+
'TMPDIR' => @working_directory,
|
52
|
+
'TEMP' => @working_directory,
|
53
|
+
'TMP' => @working_directory
|
54
|
+
}
|
55
|
+
end
|
56
|
+
|
57
|
+
def validate_execution_params!(language, code)
|
58
|
+
unless @allowed_languages.include?(language)
|
59
|
+
raise ToolError, "Language not allowed: #{language}. Allowed: #{@allowed_languages.join(', ')}"
|
60
|
+
end
|
61
|
+
|
62
|
+
if code.to_s.strip.empty?
|
63
|
+
raise ToolError, "Code cannot be empty"
|
64
|
+
end
|
65
|
+
|
66
|
+
if code.length > 50_000 # 50KB max
|
67
|
+
raise ToolError, "Code too long: #{code.length} characters (max: 50,000)"
|
68
|
+
end
|
69
|
+
|
70
|
+
validate_code_safety!(language, code)
|
71
|
+
end
|
72
|
+
|
73
|
+
def validate_code_safety!(language, code)
|
74
|
+
code_lower = code.downcase
|
75
|
+
|
76
|
+
# Common dangerous patterns across languages
|
77
|
+
dangerous_patterns = [
|
78
|
+
# System operations
|
79
|
+
'system(', 'exec(', 'eval(', 'subprocess', 'os.system',
|
80
|
+
'shell_exec', 'passthru', '`', 'require "open3"',
|
81
|
+
|
82
|
+
# File operations (if not explicitly enabled)
|
83
|
+
'file.open', 'open(', 'with open', 'fopen', 'file_get_contents',
|
84
|
+
'file_put_contents', 'unlink', 'delete', 'remove',
|
85
|
+
|
86
|
+
# Network operations
|
87
|
+
'socket', 'http', 'urllib', 'requests', 'net::',
|
88
|
+
'tcpsocket', 'udpsocket', 'curl', 'wget',
|
89
|
+
|
90
|
+
# Process operations
|
91
|
+
'fork', 'spawn', 'thread', 'process',
|
92
|
+
|
93
|
+
# Dangerous Python imports
|
94
|
+
'import os', 'import sys', 'import subprocess', 'import socket',
|
95
|
+
'from os', 'from sys', 'from subprocess',
|
96
|
+
|
97
|
+
# Dangerous Ruby requires
|
98
|
+
'require "fileutils"', 'require "open3"', 'require "net/',
|
99
|
+
|
100
|
+
# Shell commands
|
101
|
+
'rm -', 'sudo', 'chmod', 'chown', 'dd if=', 'mkfs',
|
102
|
+
'iptables', 'systemctl', 'service '
|
103
|
+
]
|
104
|
+
|
105
|
+
unless @enable_file_operations
|
106
|
+
dangerous_patterns += [
|
107
|
+
'file.', 'open(', 'with open', 'fopen', 'file_get_contents',
|
108
|
+
'file_put_contents', 'fileutils', 'fs.', 'path.', 'dir.'
|
109
|
+
]
|
110
|
+
end
|
111
|
+
|
112
|
+
dangerous_patterns.each do |pattern|
|
113
|
+
if code_lower.include?(pattern)
|
114
|
+
raise ToolError, "Potentially dangerous code detected: #{pattern}"
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
# Language-specific checks
|
119
|
+
case language
|
120
|
+
when 'python'
|
121
|
+
validate_python_safety!(code_lower)
|
122
|
+
when 'ruby'
|
123
|
+
validate_ruby_safety!(code_lower)
|
124
|
+
when 'javascript', 'node', 'js'
|
125
|
+
validate_javascript_safety!(code_lower)
|
126
|
+
when 'bash', 'sh'
|
127
|
+
validate_bash_safety!(code_lower)
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
def validate_python_safety!(code)
|
132
|
+
python_dangerous = [
|
133
|
+
'__import__', 'getattr', 'setattr', 'delattr', 'hasattr',
|
134
|
+
'globals(', 'locals(', 'vars(', 'dir(',
|
135
|
+
'compile(', 'exec(', 'eval(',
|
136
|
+
'input(', 'raw_input('
|
137
|
+
]
|
138
|
+
|
139
|
+
python_dangerous.each do |pattern|
|
140
|
+
if code.include?(pattern)
|
141
|
+
raise ToolError, "Dangerous Python construct: #{pattern}"
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
def validate_ruby_safety!(code)
|
147
|
+
ruby_dangerous = [
|
148
|
+
'instance_eval', 'class_eval', 'module_eval',
|
149
|
+
'define_method', 'send(', 'method(',
|
150
|
+
'const_get', 'const_set', 'remove_const',
|
151
|
+
'gets', 'readline', 'readlines'
|
152
|
+
]
|
153
|
+
|
154
|
+
ruby_dangerous.each do |pattern|
|
155
|
+
if code.include?(pattern)
|
156
|
+
raise ToolError, "Dangerous Ruby construct: #{pattern}"
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
def validate_javascript_safety!(code)
|
162
|
+
js_dangerous = [
|
163
|
+
'eval(', 'function(', '=>', 'require(',
|
164
|
+
'process.', 'global.', 'this.',
|
165
|
+
'document.', 'window.', 'location.',
|
166
|
+
'settimeout', 'setinterval'
|
167
|
+
]
|
168
|
+
|
169
|
+
js_dangerous.each do |pattern|
|
170
|
+
if code.include?(pattern)
|
171
|
+
raise ToolError, "Dangerous JavaScript construct: #{pattern}"
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
def validate_bash_safety!(code)
|
177
|
+
bash_dangerous = [
|
178
|
+
'rm ', 'sudo', 'su ', 'chmod', 'chown',
|
179
|
+
'>', '>>', '|', '&', ';',
|
180
|
+
'$(', '`', 'eval ', 'exec ',
|
181
|
+
'/etc/', '/proc/', '/sys/', '/dev/'
|
182
|
+
]
|
183
|
+
|
184
|
+
bash_dangerous.each do |pattern|
|
185
|
+
if code.include?(pattern)
|
186
|
+
raise ToolError, "Dangerous bash construct: #{pattern}"
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|
190
|
+
|
191
|
+
def execute_code(language, code, args, stdin_input)
|
192
|
+
case language
|
193
|
+
when 'python', 'python3'
|
194
|
+
execute_python(code, args, stdin_input)
|
195
|
+
when 'ruby'
|
196
|
+
execute_ruby(code, args, stdin_input)
|
197
|
+
when 'javascript', 'node', 'js'
|
198
|
+
execute_javascript(code, args, stdin_input)
|
199
|
+
when 'bash', 'sh'
|
200
|
+
execute_bash(code, args, stdin_input)
|
201
|
+
else
|
202
|
+
raise ToolError, "Execution not implemented for language: #{language}"
|
203
|
+
end
|
204
|
+
end
|
205
|
+
|
206
|
+
def execute_python(code, args, stdin_input)
|
207
|
+
with_temp_file(code, '.py') do |file_path|
|
208
|
+
command = ['python3', file_path] + args.map(&:to_s)
|
209
|
+
execute_command(command, stdin_input)
|
210
|
+
end
|
211
|
+
end
|
212
|
+
|
213
|
+
def execute_ruby(code, args, stdin_input)
|
214
|
+
with_temp_file(code, '.rb') do |file_path|
|
215
|
+
command = ['ruby', file_path] + args.map(&:to_s)
|
216
|
+
execute_command(command, stdin_input)
|
217
|
+
end
|
218
|
+
end
|
219
|
+
|
220
|
+
def execute_javascript(code, args, stdin_input)
|
221
|
+
with_temp_file(code, '.js') do |file_path|
|
222
|
+
command = ['node', file_path] + args.map(&:to_s)
|
223
|
+
execute_command(command, stdin_input)
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
def execute_bash(code, args, stdin_input)
|
228
|
+
with_temp_file(code, '.sh') do |file_path|
|
229
|
+
File.chmod(0o755, file_path)
|
230
|
+
command = ['bash', file_path] + args.map(&:to_s)
|
231
|
+
execute_command(command, stdin_input)
|
232
|
+
end
|
233
|
+
end
|
234
|
+
|
235
|
+
def with_temp_file(content, extension)
|
236
|
+
temp_file = Tempfile.new(['rcrewai_code', extension], @working_directory)
|
237
|
+
begin
|
238
|
+
temp_file.write(content)
|
239
|
+
temp_file.flush
|
240
|
+
temp_file.close
|
241
|
+
yield temp_file.path
|
242
|
+
ensure
|
243
|
+
temp_file.unlink if temp_file
|
244
|
+
end
|
245
|
+
end
|
246
|
+
|
247
|
+
def execute_command(command, stdin_input)
|
248
|
+
stdout_str = ""
|
249
|
+
stderr_str = ""
|
250
|
+
exit_status = nil
|
251
|
+
|
252
|
+
# Execute with timeout and security restrictions
|
253
|
+
Open3.popen3(@secure_env, *command, chdir: @working_directory) do |stdin, stdout, stderr, wait_thread|
|
254
|
+
# Write stdin if provided
|
255
|
+
if stdin_input
|
256
|
+
stdin.write(stdin_input)
|
257
|
+
stdin.close
|
258
|
+
end
|
259
|
+
|
260
|
+
# Set up timeout
|
261
|
+
timeout_thread = Thread.new do
|
262
|
+
sleep @timeout
|
263
|
+
Process.kill('KILL', wait_thread.pid) rescue nil
|
264
|
+
end
|
265
|
+
|
266
|
+
begin
|
267
|
+
# Read output
|
268
|
+
stdout_str = read_with_limit(stdout, @max_output_size)
|
269
|
+
stderr_str = read_with_limit(stderr, @max_output_size)
|
270
|
+
|
271
|
+
exit_status = wait_thread.value.exitstatus
|
272
|
+
rescue => e
|
273
|
+
exit_status = -1
|
274
|
+
stderr_str += "\nExecution error: #{e.message}"
|
275
|
+
ensure
|
276
|
+
timeout_thread.kill
|
277
|
+
end
|
278
|
+
end
|
279
|
+
|
280
|
+
{
|
281
|
+
stdout: stdout_str,
|
282
|
+
stderr: stderr_str,
|
283
|
+
exit_status: exit_status,
|
284
|
+
success: exit_status == 0
|
285
|
+
}
|
286
|
+
end
|
287
|
+
|
288
|
+
def read_with_limit(io, limit)
|
289
|
+
output = ""
|
290
|
+
while output.bytesize < limit && !io.eof?
|
291
|
+
chunk = io.read(1024)
|
292
|
+
break unless chunk
|
293
|
+
|
294
|
+
if output.bytesize + chunk.bytesize > limit
|
295
|
+
remaining = limit - output.bytesize
|
296
|
+
output += chunk[0, remaining]
|
297
|
+
output += "\n[OUTPUT TRUNCATED - LIMIT EXCEEDED]"
|
298
|
+
break
|
299
|
+
else
|
300
|
+
output += chunk
|
301
|
+
end
|
302
|
+
end
|
303
|
+
output
|
304
|
+
end
|
305
|
+
|
306
|
+
def format_execution_result(language, code, result)
|
307
|
+
output = []
|
308
|
+
output << "Code Execution Result (#{language.upcase})"
|
309
|
+
output << "=" * 40
|
310
|
+
output << "Exit Status: #{result[:exit_status]} (#{result[:success] ? 'SUCCESS' : 'FAILURE'})"
|
311
|
+
output << ""
|
312
|
+
|
313
|
+
if result[:stdout] && !result[:stdout].empty?
|
314
|
+
output << "STDOUT:"
|
315
|
+
output << result[:stdout]
|
316
|
+
output << ""
|
317
|
+
end
|
318
|
+
|
319
|
+
if result[:stderr] && !result[:stderr].empty?
|
320
|
+
output << "STDERR:"
|
321
|
+
output << result[:stderr]
|
322
|
+
output << ""
|
323
|
+
end
|
324
|
+
|
325
|
+
if result[:stdout].empty? && result[:stderr].empty?
|
326
|
+
output << "No output produced."
|
327
|
+
end
|
328
|
+
|
329
|
+
output.join("\n")
|
330
|
+
end
|
331
|
+
end
|
332
|
+
end
|
333
|
+
end
|
@@ -0,0 +1,210 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
require 'net/smtp'
|
5
|
+
require 'mail'
|
6
|
+
|
7
|
+
module RCrewAI
|
8
|
+
module Tools
|
9
|
+
class EmailSender < Base
|
10
|
+
def initialize(**options)
|
11
|
+
super()
|
12
|
+
@name = 'emailsender'
|
13
|
+
@description = 'Send emails via SMTP'
|
14
|
+
@smtp_server = options[:smtp_server] || 'localhost'
|
15
|
+
@smtp_port = options[:smtp_port] || 587
|
16
|
+
@username = options[:username]
|
17
|
+
@password = options[:password]
|
18
|
+
@from_address = options[:from_address] || @username
|
19
|
+
@use_tls = options.fetch(:use_tls, true)
|
20
|
+
@max_recipients = options.fetch(:max_recipients, 10)
|
21
|
+
setup_mail_configuration
|
22
|
+
end
|
23
|
+
|
24
|
+
def execute(**params)
|
25
|
+
validate_params!(
|
26
|
+
params,
|
27
|
+
required: [:to, :subject, :body],
|
28
|
+
optional: [:cc, :bcc, :reply_to, :attachments]
|
29
|
+
)
|
30
|
+
|
31
|
+
to_addresses = normalize_email_addresses(params[:to])
|
32
|
+
cc_addresses = normalize_email_addresses(params[:cc]) if params[:cc]
|
33
|
+
bcc_addresses = normalize_email_addresses(params[:bcc]) if params[:bcc]
|
34
|
+
|
35
|
+
begin
|
36
|
+
validate_email_params!(to_addresses, cc_addresses, bcc_addresses, params)
|
37
|
+
result = send_email(to_addresses, cc_addresses, bcc_addresses, params)
|
38
|
+
format_email_result(result, to_addresses)
|
39
|
+
rescue => e
|
40
|
+
"Email sending failed: #{e.message}"
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
def setup_mail_configuration
|
47
|
+
Mail.defaults do
|
48
|
+
delivery_method :smtp, {
|
49
|
+
address: @smtp_server,
|
50
|
+
port: @smtp_port,
|
51
|
+
user_name: @username,
|
52
|
+
password: @password,
|
53
|
+
authentication: 'login',
|
54
|
+
enable_starttls_auto: @use_tls
|
55
|
+
}
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def normalize_email_addresses(addresses)
|
60
|
+
case addresses
|
61
|
+
when String
|
62
|
+
addresses.split(/[,;]/).map(&:strip)
|
63
|
+
when Array
|
64
|
+
addresses.map(&:to_s).map(&:strip)
|
65
|
+
else
|
66
|
+
[addresses.to_s.strip]
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def validate_email_params!(to_addresses, cc_addresses, bcc_addresses, params)
|
71
|
+
# Validate email addresses
|
72
|
+
all_addresses = to_addresses + (cc_addresses || []) + (bcc_addresses || [])
|
73
|
+
|
74
|
+
all_addresses.each do |address|
|
75
|
+
unless valid_email?(address)
|
76
|
+
raise ToolError, "Invalid email address: #{address}"
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
# Check recipient limits
|
81
|
+
if all_addresses.length > @max_recipients
|
82
|
+
raise ToolError, "Too many recipients: #{all_addresses.length} (max: #{@max_recipients})"
|
83
|
+
end
|
84
|
+
|
85
|
+
# Validate subject and body
|
86
|
+
if params[:subject].to_s.strip.empty?
|
87
|
+
raise ToolError, "Email subject cannot be empty"
|
88
|
+
end
|
89
|
+
|
90
|
+
if params[:body].to_s.strip.empty?
|
91
|
+
raise ToolError, "Email body cannot be empty"
|
92
|
+
end
|
93
|
+
|
94
|
+
# Check for spam-like content
|
95
|
+
validate_content_safety!(params[:subject], params[:body])
|
96
|
+
end
|
97
|
+
|
98
|
+
def valid_email?(address)
|
99
|
+
address.match?(/\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i)
|
100
|
+
end
|
101
|
+
|
102
|
+
def validate_content_safety!(subject, body)
|
103
|
+
# Basic spam detection
|
104
|
+
spam_indicators = [
|
105
|
+
'viagra', 'casino', 'lottery', 'winner', 'congratulations',
|
106
|
+
'million dollars', 'click here', 'urgent', 'act now'
|
107
|
+
]
|
108
|
+
|
109
|
+
content = "#{subject} #{body}".downcase
|
110
|
+
|
111
|
+
spam_indicators.each do |indicator|
|
112
|
+
if content.include?(indicator)
|
113
|
+
raise ToolError, "Potentially spam content detected: #{indicator}"
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
# Check for excessive caps
|
118
|
+
caps_ratio = content.gsub(/[^A-Z]/, '').length.to_f / content.gsub(/[^A-Za-z]/, '').length
|
119
|
+
if caps_ratio > 0.5 && content.length > 50
|
120
|
+
raise ToolError, "Excessive capitalization detected (possible spam)"
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
def send_email(to_addresses, cc_addresses, bcc_addresses, params)
|
125
|
+
mail = Mail.new do
|
126
|
+
from @from_address
|
127
|
+
to to_addresses
|
128
|
+
cc cc_addresses if cc_addresses&.any?
|
129
|
+
bcc bcc_addresses if bcc_addresses&.any?
|
130
|
+
subject params[:subject]
|
131
|
+
body params[:body]
|
132
|
+
|
133
|
+
if params[:reply_to]
|
134
|
+
reply_to params[:reply_to]
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
# Add attachments if provided
|
139
|
+
if params[:attachments]
|
140
|
+
add_attachments(mail, params[:attachments])
|
141
|
+
end
|
142
|
+
|
143
|
+
# Send the email
|
144
|
+
mail.deliver!
|
145
|
+
|
146
|
+
{
|
147
|
+
message_id: mail.message_id,
|
148
|
+
to: to_addresses,
|
149
|
+
cc: cc_addresses,
|
150
|
+
bcc: bcc_addresses,
|
151
|
+
subject: params[:subject],
|
152
|
+
sent_at: Time.now,
|
153
|
+
size: mail.to_s.bytesize
|
154
|
+
}
|
155
|
+
end
|
156
|
+
|
157
|
+
def add_attachments(mail, attachments)
|
158
|
+
attachment_list = case attachments
|
159
|
+
when String
|
160
|
+
[attachments]
|
161
|
+
when Array
|
162
|
+
attachments
|
163
|
+
else
|
164
|
+
[attachments.to_s]
|
165
|
+
end
|
166
|
+
|
167
|
+
attachment_list.each do |attachment_path|
|
168
|
+
if File.exist?(attachment_path)
|
169
|
+
# Security check - only allow certain file types
|
170
|
+
allowed_extensions = %w[.pdf .txt .doc .docx .xls .xlsx .jpg .png .gif .zip]
|
171
|
+
extension = File.extname(attachment_path).downcase
|
172
|
+
|
173
|
+
unless allowed_extensions.include?(extension)
|
174
|
+
raise ToolError, "Attachment file type not allowed: #{extension}"
|
175
|
+
end
|
176
|
+
|
177
|
+
# Size check (max 10MB per attachment)
|
178
|
+
if File.size(attachment_path) > 10_000_000
|
179
|
+
raise ToolError, "Attachment too large: #{File.basename(attachment_path)} (max 10MB)"
|
180
|
+
end
|
181
|
+
|
182
|
+
mail.add_file(attachment_path)
|
183
|
+
else
|
184
|
+
raise ToolError, "Attachment file not found: #{attachment_path}"
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
def format_email_result(result, to_addresses)
|
190
|
+
output = []
|
191
|
+
output << "Email sent successfully!"
|
192
|
+
output << "Message ID: #{result[:message_id]}"
|
193
|
+
output << "Recipients: #{to_addresses.join(', ')}"
|
194
|
+
output << "Subject: #{result[:subject]}"
|
195
|
+
output << "Sent at: #{result[:sent_at].strftime('%Y-%m-%d %H:%M:%S')}"
|
196
|
+
output << "Message size: #{result[:size]} bytes"
|
197
|
+
|
198
|
+
if result[:cc]&.any?
|
199
|
+
output << "CC: #{result[:cc].join(', ')}"
|
200
|
+
end
|
201
|
+
|
202
|
+
if result[:bcc]&.any?
|
203
|
+
output << "BCC: #{result[:bcc].length} recipients"
|
204
|
+
end
|
205
|
+
|
206
|
+
output.join("\n")
|
207
|
+
end
|
208
|
+
end
|
209
|
+
end
|
210
|
+
end
|
@@ -0,0 +1,111 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
require 'pathname'
|
5
|
+
|
6
|
+
module RCrewAI
|
7
|
+
module Tools
|
8
|
+
class FileReader < Base
|
9
|
+
def initialize(**options)
|
10
|
+
super()
|
11
|
+
@name = 'filereader'
|
12
|
+
@description = 'Read contents from files'
|
13
|
+
@max_file_size = options.fetch(:max_file_size, 10_000_000) # 10MB
|
14
|
+
@allowed_extensions = options.fetch(:allowed_extensions, %w[.txt .md .json .yaml .yml .csv .log])
|
15
|
+
end
|
16
|
+
|
17
|
+
def execute(**params)
|
18
|
+
validate_params!(params, required: [:file_path], optional: [:encoding, :lines])
|
19
|
+
|
20
|
+
file_path = params[:file_path]
|
21
|
+
encoding = params[:encoding] || 'utf-8'
|
22
|
+
lines = params[:lines] # Optional: read only N lines
|
23
|
+
|
24
|
+
begin
|
25
|
+
read_file(file_path, encoding, lines)
|
26
|
+
rescue => e
|
27
|
+
"File read failed: #{e.message}"
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def read_file(file_path, encoding, lines = nil)
|
34
|
+
path = Pathname.new(file_path)
|
35
|
+
|
36
|
+
# Security checks
|
37
|
+
validate_file_path!(path)
|
38
|
+
validate_file_size!(path)
|
39
|
+
validate_file_extension!(path)
|
40
|
+
|
41
|
+
content = if lines
|
42
|
+
read_lines(path, encoding, lines)
|
43
|
+
else
|
44
|
+
read_full_file(path, encoding)
|
45
|
+
end
|
46
|
+
|
47
|
+
format_file_content(path, content, lines)
|
48
|
+
end
|
49
|
+
|
50
|
+
def validate_file_path!(path)
|
51
|
+
raise ToolError, "File does not exist: #{path}" unless path.exist?
|
52
|
+
raise ToolError, "Path is not a file: #{path}" unless path.file?
|
53
|
+
raise ToolError, "File is not readable: #{path}" unless path.readable?
|
54
|
+
|
55
|
+
# Prevent directory traversal
|
56
|
+
resolved_path = path.realpath.to_s
|
57
|
+
working_dir = Dir.pwd
|
58
|
+
unless resolved_path.start_with?(working_dir)
|
59
|
+
raise ToolError, "Access denied: file outside working directory"
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def validate_file_size!(path)
|
64
|
+
size = path.size
|
65
|
+
if size > @max_file_size
|
66
|
+
raise ToolError, "File too large: #{size} bytes (max: #{@max_file_size})"
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def validate_file_extension!(path)
|
71
|
+
extension = path.extname.downcase
|
72
|
+
unless @allowed_extensions.include?(extension) || @allowed_extensions.include?('*')
|
73
|
+
raise ToolError, "File type not allowed: #{extension}"
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def read_lines(path, encoding, line_count)
|
78
|
+
lines = []
|
79
|
+
File.open(path, 'r', encoding: encoding) do |file|
|
80
|
+
line_count.times do
|
81
|
+
line = file.gets
|
82
|
+
break unless line
|
83
|
+
lines << line.chomp
|
84
|
+
end
|
85
|
+
end
|
86
|
+
lines.join("\n")
|
87
|
+
end
|
88
|
+
|
89
|
+
def read_full_file(path, encoding)
|
90
|
+
File.read(path, encoding: encoding)
|
91
|
+
end
|
92
|
+
|
93
|
+
def format_file_content(path, content, lines = nil)
|
94
|
+
header = "File: #{path.basename}"
|
95
|
+
header += " (first #{lines} lines)" if lines
|
96
|
+
header += "\nSize: #{path.size} bytes"
|
97
|
+
header += "\nModified: #{path.mtime.strftime('%Y-%m-%d %H:%M:%S')}"
|
98
|
+
header += "\n" + "="*50 + "\n"
|
99
|
+
|
100
|
+
# Truncate very long content for display
|
101
|
+
display_content = if content.length > 5000
|
102
|
+
content[0..4997] + "..."
|
103
|
+
else
|
104
|
+
content
|
105
|
+
end
|
106
|
+
|
107
|
+
header + display_content
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|