crucible 0.1.2

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 (42) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +102 -0
  4. data/Gemfile +10 -0
  5. data/LICENSE +21 -0
  6. data/README.md +366 -0
  7. data/Rakefile +23 -0
  8. data/TESTING.md +319 -0
  9. data/config.sample.yml +48 -0
  10. data/crucible.gemspec +48 -0
  11. data/exe/crucible +122 -0
  12. data/lib/crucible/configuration.rb +212 -0
  13. data/lib/crucible/server.rb +123 -0
  14. data/lib/crucible/session_manager.rb +209 -0
  15. data/lib/crucible/stealth/evasions/chrome_app.js +75 -0
  16. data/lib/crucible/stealth/evasions/chrome_csi.js +33 -0
  17. data/lib/crucible/stealth/evasions/chrome_load_times.js +44 -0
  18. data/lib/crucible/stealth/evasions/chrome_runtime.js +190 -0
  19. data/lib/crucible/stealth/evasions/iframe_content_window.js +101 -0
  20. data/lib/crucible/stealth/evasions/media_codecs.js +65 -0
  21. data/lib/crucible/stealth/evasions/navigator_hardware_concurrency.js +18 -0
  22. data/lib/crucible/stealth/evasions/navigator_languages.js +18 -0
  23. data/lib/crucible/stealth/evasions/navigator_permissions.js +53 -0
  24. data/lib/crucible/stealth/evasions/navigator_plugins.js +261 -0
  25. data/lib/crucible/stealth/evasions/navigator_vendor.js +18 -0
  26. data/lib/crucible/stealth/evasions/navigator_webdriver.js +16 -0
  27. data/lib/crucible/stealth/evasions/webgl_vendor.js +43 -0
  28. data/lib/crucible/stealth/evasions/window_outerdimensions.js +18 -0
  29. data/lib/crucible/stealth/utils.js +266 -0
  30. data/lib/crucible/stealth.rb +213 -0
  31. data/lib/crucible/tools/cookies.rb +206 -0
  32. data/lib/crucible/tools/downloads.rb +273 -0
  33. data/lib/crucible/tools/extraction.rb +335 -0
  34. data/lib/crucible/tools/helpers.rb +46 -0
  35. data/lib/crucible/tools/interaction.rb +355 -0
  36. data/lib/crucible/tools/navigation.rb +181 -0
  37. data/lib/crucible/tools/sessions.rb +85 -0
  38. data/lib/crucible/tools/stealth.rb +167 -0
  39. data/lib/crucible/tools.rb +42 -0
  40. data/lib/crucible/version.rb +5 -0
  41. data/lib/crucible.rb +60 -0
  42. metadata +201 -0
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mcp'
4
+ require 'json'
5
+
6
+ module Crucible
7
+ module Tools
8
+ # Cookie management tools: get_cookies, set_cookies, clear_cookies
9
+ module Cookies
10
+ class << self
11
+ def tools(sessions, _config)
12
+ [
13
+ get_cookies_tool(sessions),
14
+ set_cookies_tool(sessions),
15
+ clear_cookies_tool(sessions)
16
+ ]
17
+ end
18
+
19
+ private
20
+
21
+ def get_cookies_tool(sessions)
22
+ MCP::Tool.define(
23
+ name: 'get_cookies',
24
+ description: 'Get cookies from the current page',
25
+ input_schema: {
26
+ type: 'object',
27
+ properties: {
28
+ session: {
29
+ type: 'string',
30
+ description: 'Session name',
31
+ default: 'default'
32
+ },
33
+ name: {
34
+ type: 'string',
35
+ description: 'Specific cookie name to get (optional, returns all if not specified)'
36
+ }
37
+ },
38
+ required: []
39
+ }
40
+ ) do |session: 'default', name: nil, **|
41
+ page = sessions.page(session)
42
+
43
+ # Helper to convert cookie to hash
44
+ to_hash = lambda do |c|
45
+ {
46
+ name: c.name,
47
+ value: c.value,
48
+ domain: c.domain,
49
+ path: c.path,
50
+ secure: c.secure?,
51
+ httpOnly: c.httponly?,
52
+ sameSite: c.samesite,
53
+ expires: c.expires
54
+ }.compact
55
+ end
56
+
57
+ cookies = if name
58
+ cookie = page.cookies[name]
59
+ cookie ? [to_hash.call(cookie)] : []
60
+ else
61
+ page.cookies.all.values.map { |c| to_hash.call(c) }
62
+ end
63
+
64
+ MCP::Tool::Response.new([{ type: 'text', text: JSON.pretty_generate(cookies) }])
65
+ rescue Ferrum::Error => e
66
+ MCP::Tool::Response.new([{ type: 'text', text: "Get cookies failed: #{e.message}" }], error: true)
67
+ end
68
+ end
69
+
70
+ def set_cookies_tool(sessions)
71
+ MCP::Tool.define(
72
+ name: 'set_cookies',
73
+ description: 'Set one or more cookies',
74
+ input_schema: {
75
+ type: 'object',
76
+ properties: {
77
+ session: {
78
+ type: 'string',
79
+ description: 'Session name',
80
+ default: 'default'
81
+ },
82
+ cookies: {
83
+ type: 'array',
84
+ description: 'Array of cookies to set',
85
+ items: {
86
+ type: 'object',
87
+ properties: {
88
+ name: {
89
+ type: 'string',
90
+ description: 'Cookie name'
91
+ },
92
+ value: {
93
+ type: 'string',
94
+ description: 'Cookie value'
95
+ },
96
+ domain: {
97
+ type: 'string',
98
+ description: 'Cookie domain'
99
+ },
100
+ path: {
101
+ type: 'string',
102
+ description: 'Cookie path',
103
+ default: '/'
104
+ },
105
+ secure: {
106
+ type: 'boolean',
107
+ description: 'Secure cookie flag',
108
+ default: false
109
+ },
110
+ httpOnly: {
111
+ type: 'boolean',
112
+ description: 'HttpOnly cookie flag',
113
+ default: false
114
+ },
115
+ sameSite: {
116
+ type: 'string',
117
+ description: 'SameSite attribute',
118
+ enum: %w[Strict Lax None]
119
+ },
120
+ expires: {
121
+ type: 'integer',
122
+ description: 'Expiration timestamp (Unix epoch)'
123
+ }
124
+ },
125
+ required: %w[name value]
126
+ }
127
+ }
128
+ },
129
+ required: ['cookies']
130
+ }
131
+ ) do |cookies:, session: 'default', **|
132
+ page = sessions.page(session)
133
+
134
+ cookies.each do |cookie|
135
+ cookie_opts = {
136
+ name: cookie[:name] || cookie['name'],
137
+ value: cookie[:value] || cookie['value']
138
+ }
139
+
140
+ # Add optional fields if present
141
+ domain = cookie[:domain] || cookie['domain']
142
+ cookie_opts[:domain] = domain if domain
143
+
144
+ path = cookie[:path] || cookie['path']
145
+ cookie_opts[:path] = path if path
146
+
147
+ secure = cookie[:secure] || cookie['secure']
148
+ cookie_opts[:secure] = secure unless secure.nil?
149
+
150
+ http_only = cookie[:httpOnly] || cookie['httpOnly']
151
+ cookie_opts[:httponly] = http_only unless http_only.nil?
152
+
153
+ same_site = cookie[:sameSite] || cookie['sameSite']
154
+ cookie_opts[:samesite] = same_site if same_site
155
+
156
+ expires = cookie[:expires] || cookie['expires']
157
+ cookie_opts[:expires] = expires if expires
158
+
159
+ page.cookies.set(**cookie_opts)
160
+ end
161
+
162
+ MCP::Tool::Response.new([{ type: 'text', text: "Set #{cookies.size} cookie(s)" }])
163
+ rescue Ferrum::Error => e
164
+ MCP::Tool::Response.new([{ type: 'text', text: "Set cookies failed: #{e.message}" }], error: true)
165
+ end
166
+ end
167
+
168
+ def clear_cookies_tool(sessions)
169
+ MCP::Tool.define(
170
+ name: 'clear_cookies',
171
+ description: 'Clear all cookies or a specific cookie',
172
+ input_schema: {
173
+ type: 'object',
174
+ properties: {
175
+ session: {
176
+ type: 'string',
177
+ description: 'Session name',
178
+ default: 'default'
179
+ },
180
+ name: {
181
+ type: 'string',
182
+ description: 'Specific cookie name to clear (optional, clears all if not specified)'
183
+ }
184
+ },
185
+ required: []
186
+ }
187
+ ) do |session: 'default', name: nil, **|
188
+ page = sessions.page(session)
189
+
190
+ if name
191
+ # Ferrum requires domain or url to remove a specific cookie
192
+ url = page.current_url
193
+ page.cookies.remove(name: name, url: url)
194
+ MCP::Tool::Response.new([{ type: 'text', text: "Cleared cookie: #{name}" }])
195
+ else
196
+ page.cookies.clear
197
+ MCP::Tool::Response.new([{ type: 'text', text: 'Cleared all cookies' }])
198
+ end
199
+ rescue Ferrum::Error => e
200
+ MCP::Tool::Response.new([{ type: 'text', text: "Clear cookies failed: #{e.message}" }], error: true)
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,273 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mcp'
4
+ require 'json'
5
+ require 'fileutils'
6
+
7
+ module Crucible
8
+ module Tools
9
+ # Download management tools: set_download_path, list_downloads, wait_for_download, clear_downloads
10
+ module Downloads
11
+ # Tracker to persist download info across navigations (Ferrum clears its list on navigation)
12
+ class Tracker
13
+ def initialize
14
+ @sessions = {}
15
+ @mutex = Mutex.new
16
+ end
17
+
18
+ def set_path(session, path)
19
+ @mutex.synchronize do
20
+ @sessions[session] ||= { path: nil, files: [], initial_files: [] }
21
+ @sessions[session][:path] = path
22
+ # Capture initial directory state to detect new downloads
23
+ @sessions[session][:initial_files] = Dir.exist?(path) ? Dir.glob(File.join(path, '*')) : []
24
+ end
25
+ end
26
+
27
+ def get_path(session)
28
+ @mutex.synchronize { @sessions.dig(session, :path) }
29
+ end
30
+
31
+ def get_initial_files(session)
32
+ @mutex.synchronize { @sessions.dig(session, :initial_files) || [] }
33
+ end
34
+
35
+ def add_files(session, files)
36
+ @mutex.synchronize do
37
+ @sessions[session] ||= { path: nil, files: [], initial_files: [] }
38
+ @sessions[session][:files] = (@sessions[session][:files] + files).uniq
39
+ end
40
+ end
41
+
42
+ def get_files(session)
43
+ @mutex.synchronize { @sessions.dig(session, :files) || [] }
44
+ end
45
+
46
+ def clear_files(session)
47
+ @mutex.synchronize do
48
+ cleared = @sessions.dig(session, :files) || []
49
+ @sessions[session][:files] = [] if @sessions[session]
50
+ cleared
51
+ end
52
+ end
53
+
54
+ def remove_session(session)
55
+ @mutex.synchronize { @sessions.delete(session) }
56
+ end
57
+ end
58
+
59
+ class << self
60
+ def tools(sessions, _config)
61
+ # Create a shared tracker instance for all tools
62
+ tracker = Tracker.new
63
+
64
+ [
65
+ set_download_path_tool(sessions, tracker),
66
+ list_downloads_tool(sessions, tracker),
67
+ wait_for_download_tool(sessions, tracker),
68
+ clear_downloads_tool(sessions, tracker)
69
+ ]
70
+ end
71
+
72
+ private
73
+
74
+ def set_download_path_tool(sessions, tracker)
75
+ MCP::Tool.define(
76
+ name: 'set_download_path',
77
+ description: 'Set the directory where downloads will be saved',
78
+ input_schema: {
79
+ type: 'object',
80
+ properties: {
81
+ session: {
82
+ type: 'string',
83
+ description: 'Session name',
84
+ default: 'default'
85
+ },
86
+ path: {
87
+ type: 'string',
88
+ description: 'Directory path for downloads'
89
+ }
90
+ },
91
+ required: ['path']
92
+ }
93
+ ) do |path:, session: 'default', **|
94
+ browser = sessions.get_or_create(session)
95
+ expanded_path = File.expand_path(path)
96
+
97
+ # Create directory if it doesn't exist
98
+ FileUtils.mkdir_p(expanded_path)
99
+
100
+ # Set in Ferrum
101
+ browser.downloads.set_behavior(save_path: expanded_path)
102
+
103
+ # Track in our persistent tracker
104
+ tracker.set_path(session, expanded_path)
105
+
106
+ MCP::Tool::Response.new([{
107
+ type: 'text',
108
+ text: "Download path set to: #{expanded_path}"
109
+ }])
110
+ rescue Ferrum::Error => e
111
+ MCP::Tool::Response.new([{ type: 'text', text: "Failed to set download path: #{e.message}" }], error: true)
112
+ end
113
+ end
114
+
115
+ def list_downloads_tool(_sessions, tracker)
116
+ MCP::Tool.define(
117
+ name: 'list_downloads',
118
+ description: 'List all downloaded files in the current session',
119
+ input_schema: {
120
+ type: 'object',
121
+ properties: {
122
+ session: {
123
+ type: 'string',
124
+ description: 'Session name',
125
+ default: 'default'
126
+ }
127
+ },
128
+ required: []
129
+ }
130
+ ) do |session: 'default', **|
131
+ # Use our tracker instead of Ferrum's (which gets cleared on navigation)
132
+ files = tracker.get_files(session)
133
+
134
+ if files.empty?
135
+ MCP::Tool::Response.new([{ type: 'text', text: 'No downloads in this session' }])
136
+ else
137
+ result = {
138
+ download_path: tracker.get_path(session),
139
+ count: files.size,
140
+ files: files.map do |file|
141
+ info = { path: file, filename: File.basename(file) }
142
+ if File.exist?(file)
143
+ info[:size] = File.size(file)
144
+ info[:modified] = File.mtime(file).iso8601
145
+ info[:exists] = true
146
+ else
147
+ info[:exists] = false
148
+ end
149
+ info
150
+ end
151
+ }
152
+ MCP::Tool::Response.new([{ type: 'text', text: JSON.pretty_generate(result) }])
153
+ end
154
+ end
155
+ end
156
+
157
+ def wait_for_download_tool(sessions, tracker)
158
+ MCP::Tool.define(
159
+ name: 'wait_for_download',
160
+ description: 'Wait for a download to complete',
161
+ input_schema: {
162
+ type: 'object',
163
+ properties: {
164
+ session: {
165
+ type: 'string',
166
+ description: 'Session name',
167
+ default: 'default'
168
+ },
169
+ timeout: {
170
+ type: 'number',
171
+ description: 'Maximum time to wait in seconds',
172
+ default: 30
173
+ }
174
+ },
175
+ required: []
176
+ }
177
+ ) do |session: 'default', timeout: 30, **|
178
+ browser = sessions.get_or_create(session)
179
+ download_path = tracker.get_path(session)
180
+
181
+ # Try Ferrum's wait first (may or may not work depending on download type)
182
+ begin
183
+ browser.downloads.wait(timeout)
184
+ rescue Ferrum::TimeoutError
185
+ # Timeout is ok - download might have already completed
186
+ end
187
+
188
+ # Find new files by comparing current directory against:
189
+ # 1. Initial files when set_download_path was called
190
+ # 2. Files we've already tracked
191
+ new_files = []
192
+ if download_path && Dir.exist?(download_path)
193
+ current_files = Dir.glob(File.join(download_path, '*'))
194
+ initial_files = tracker.get_initial_files(session)
195
+ already_tracked = tracker.get_files(session)
196
+ known_files = (initial_files + already_tracked).uniq
197
+
198
+ new_files = current_files - known_files
199
+ end
200
+
201
+ # Add to our persistent tracker
202
+ tracker.add_files(session, new_files) unless new_files.empty?
203
+
204
+ if new_files.empty?
205
+ tracked_count = tracker.get_files(session).size
206
+ MCP::Tool::Response.new([{
207
+ type: 'text',
208
+ text: "No new downloads. Total tracked files: #{tracked_count}"
209
+ }])
210
+ else
211
+ result = new_files.map do |file|
212
+ info = { path: file, filename: File.basename(file) }
213
+ info[:size] = File.size(file) if File.exist?(file)
214
+ info
215
+ end
216
+ MCP::Tool::Response.new([{
217
+ type: 'text',
218
+ text: "Downloaded: #{JSON.pretty_generate(result)}"
219
+ }])
220
+ end
221
+ rescue Ferrum::Error => e
222
+ MCP::Tool::Response.new([{ type: 'text', text: "Failed waiting for download: #{e.message}" }], error: true)
223
+ end
224
+ end
225
+
226
+ def clear_downloads_tool(_sessions, tracker)
227
+ MCP::Tool.define(
228
+ name: 'clear_downloads',
229
+ description: 'Clear the list of tracked downloads (optionally delete files)',
230
+ input_schema: {
231
+ type: 'object',
232
+ properties: {
233
+ session: {
234
+ type: 'string',
235
+ description: 'Session name',
236
+ default: 'default'
237
+ },
238
+ delete_files: {
239
+ type: 'boolean',
240
+ description: 'Also delete the actual files from disk',
241
+ default: false
242
+ }
243
+ },
244
+ required: []
245
+ }
246
+ ) do |session: 'default', delete_files: false, **|
247
+ files = tracker.clear_files(session)
248
+
249
+ deleted_count = 0
250
+ if delete_files
251
+ files.each do |file|
252
+ if File.exist?(file)
253
+ File.delete(file)
254
+ deleted_count += 1
255
+ end
256
+ end
257
+ end
258
+
259
+ message = if delete_files
260
+ "Cleared #{files.size} download(s), deleted #{deleted_count} file(s)"
261
+ else
262
+ "Cleared #{files.size} download(s) from tracking"
263
+ end
264
+
265
+ MCP::Tool::Response.new([{ type: 'text', text: message }])
266
+ rescue StandardError => e
267
+ MCP::Tool::Response.new([{ type: 'text', text: "Failed to clear downloads: #{e.message}" }], error: true)
268
+ end
269
+ end
270
+ end
271
+ end
272
+ end
273
+ end