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,355 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mcp'
4
+
5
+ module Crucible
6
+ module Tools
7
+ # Interaction tools: click, type, fill_form, select_option, scroll, hover
8
+ module Interaction
9
+ class << self
10
+ def tools(sessions, _config)
11
+ [
12
+ click_tool(sessions),
13
+ type_tool(sessions),
14
+ fill_form_tool(sessions),
15
+ select_option_tool(sessions),
16
+ scroll_tool(sessions),
17
+ hover_tool(sessions)
18
+ ]
19
+ end
20
+
21
+ private
22
+
23
+ def click_tool(sessions)
24
+ MCP::Tool.define(
25
+ name: 'click',
26
+ description: 'Click an element on the page',
27
+ input_schema: {
28
+ type: 'object',
29
+ properties: {
30
+ session: {
31
+ type: 'string',
32
+ description: 'Session name',
33
+ default: 'default'
34
+ },
35
+ selector: {
36
+ type: 'string',
37
+ description: 'CSS selector for the element to click'
38
+ },
39
+ button: {
40
+ type: 'string',
41
+ description: 'Mouse button to use',
42
+ enum: %w[left right middle],
43
+ default: 'left'
44
+ },
45
+ count: {
46
+ type: 'integer',
47
+ description: 'Number of clicks (1 for single, 2 for double)',
48
+ default: 1
49
+ }
50
+ },
51
+ required: ['selector']
52
+ }
53
+ ) do |selector:, session: 'default', button: 'left', count: 1, **|
54
+ page = sessions.page(session)
55
+ element = page.at_css(selector)
56
+
57
+ raise ElementNotFoundError, "Element not found: #{selector}" unless element
58
+
59
+ # Ferrum uses mode: for click type - :left, :right, or :double
60
+ mode = if count == 2
61
+ :double
62
+ else
63
+ button.to_sym
64
+ end
65
+
66
+ element.click(mode: mode)
67
+
68
+ MCP::Tool::Response.new([{ type: 'text', text: "Clicked: #{selector}" }])
69
+ rescue ElementNotFoundError => e
70
+ MCP::Tool::Response.new([{ type: 'text', text: e.message }], error: true)
71
+ rescue Ferrum::Error => e
72
+ MCP::Tool::Response.new([{ type: 'text', text: "Click failed: #{e.message}" }], error: true)
73
+ end
74
+ end
75
+
76
+ def type_tool(sessions)
77
+ MCP::Tool.define(
78
+ name: 'type',
79
+ description: 'Type text into an input element',
80
+ input_schema: {
81
+ type: 'object',
82
+ properties: {
83
+ session: {
84
+ type: 'string',
85
+ description: 'Session name',
86
+ default: 'default'
87
+ },
88
+ selector: {
89
+ type: 'string',
90
+ description: 'CSS selector for the input element'
91
+ },
92
+ text: {
93
+ type: 'string',
94
+ description: 'Text to type'
95
+ },
96
+ clear: {
97
+ type: 'boolean',
98
+ description: 'Clear the field before typing',
99
+ default: false
100
+ },
101
+ submit: {
102
+ type: 'boolean',
103
+ description: 'Press Enter after typing',
104
+ default: false
105
+ }
106
+ },
107
+ required: %w[selector text]
108
+ }
109
+ ) do |selector:, text:, session: 'default', clear: false, submit: false, **|
110
+ page = sessions.page(session)
111
+ element = page.at_css(selector)
112
+
113
+ raise ElementNotFoundError, "Element not found: #{selector}" unless element
114
+
115
+ element.focus
116
+
117
+ if clear
118
+ # Select all and delete (use meta on macOS, control elsewhere)
119
+ modifier = RUBY_PLATFORM.include?('darwin') ? :meta : :control
120
+ element.type([modifier, 'a'], [:backspace])
121
+ end
122
+
123
+ if submit
124
+ element.type(text, :Enter)
125
+ else
126
+ element.type(text)
127
+ end
128
+
129
+ MCP::Tool::Response.new([{ type: 'text', text: "Typed into: #{selector}" }])
130
+ rescue ElementNotFoundError => e
131
+ MCP::Tool::Response.new([{ type: 'text', text: e.message }], error: true)
132
+ rescue Ferrum::Error => e
133
+ MCP::Tool::Response.new([{ type: 'text', text: "Type failed: #{e.message}" }], error: true)
134
+ end
135
+ end
136
+
137
+ def fill_form_tool(sessions)
138
+ MCP::Tool.define(
139
+ name: 'fill_form',
140
+ description: 'Fill multiple form fields at once',
141
+ input_schema: {
142
+ type: 'object',
143
+ properties: {
144
+ session: {
145
+ type: 'string',
146
+ description: 'Session name',
147
+ default: 'default'
148
+ },
149
+ fields: {
150
+ type: 'array',
151
+ description: 'Array of fields to fill',
152
+ items: {
153
+ type: 'object',
154
+ properties: {
155
+ selector: {
156
+ type: 'string',
157
+ description: 'CSS selector for the field'
158
+ },
159
+ value: {
160
+ type: 'string',
161
+ description: 'Value to enter'
162
+ }
163
+ },
164
+ required: %w[selector value]
165
+ }
166
+ }
167
+ },
168
+ required: ['fields']
169
+ }
170
+ ) do |fields:, session: 'default', **|
171
+ page = sessions.page(session)
172
+ filled = []
173
+ errors = []
174
+
175
+ fields.each do |field|
176
+ field_selector = field[:selector] || field['selector']
177
+ value = field[:value] || field['value']
178
+
179
+ element = page.at_css(field_selector)
180
+ if element
181
+ element.focus
182
+ # Clear first (use meta on macOS, control elsewhere)
183
+ modifier = RUBY_PLATFORM.include?('darwin') ? :meta : :control
184
+ element.type([modifier, 'a'], [:backspace])
185
+ element.type(value)
186
+ filled << field_selector
187
+ else
188
+ errors << field_selector
189
+ end
190
+ end
191
+
192
+ message = "Filled #{filled.size} field(s)"
193
+ message += ". Not found: #{errors.join(', ')}" if errors.any?
194
+
195
+ if errors.any? && filled.empty?
196
+ MCP::Tool::Response.new([{ type: 'text', text: message }], error: true)
197
+ else
198
+ MCP::Tool::Response.new([{ type: 'text', text: message }])
199
+ end
200
+ rescue Ferrum::Error => e
201
+ MCP::Tool::Response.new([{ type: 'text', text: "Fill form failed: #{e.message}" }], error: true)
202
+ end
203
+ end
204
+
205
+ def select_option_tool(sessions)
206
+ MCP::Tool.define(
207
+ name: 'select_option',
208
+ description: 'Select an option from a dropdown/select element',
209
+ input_schema: {
210
+ type: 'object',
211
+ properties: {
212
+ session: {
213
+ type: 'string',
214
+ description: 'Session name',
215
+ default: 'default'
216
+ },
217
+ selector: {
218
+ type: 'string',
219
+ description: 'CSS selector for the select element'
220
+ },
221
+ value: {
222
+ type: 'string',
223
+ description: 'Value or text of the option to select'
224
+ },
225
+ by: {
226
+ type: 'string',
227
+ description: 'How to match the option',
228
+ enum: %w[value text],
229
+ default: 'value'
230
+ }
231
+ },
232
+ required: %w[selector value]
233
+ }
234
+ ) do |selector:, value:, session: 'default', by: 'value', **|
235
+ _ = by # TODO: Implement selection by text/index
236
+
237
+ page = sessions.page(session)
238
+ select = page.at_css(selector)
239
+
240
+ raise ElementNotFoundError, "Select element not found: #{selector}" unless select
241
+
242
+ select.select(value)
243
+
244
+ MCP::Tool::Response.new([{ type: 'text', text: "Selected '#{value}' in: #{selector}" }])
245
+ rescue ElementNotFoundError => e
246
+ MCP::Tool::Response.new([{ type: 'text', text: e.message }], error: true)
247
+ rescue Ferrum::Error => e
248
+ MCP::Tool::Response.new([{ type: 'text', text: "Select failed: #{e.message}" }], error: true)
249
+ end
250
+ end
251
+
252
+ def scroll_tool(sessions)
253
+ MCP::Tool.define(
254
+ name: 'scroll',
255
+ description: 'Scroll the page or scroll an element into view',
256
+ input_schema: {
257
+ type: 'object',
258
+ properties: {
259
+ session: {
260
+ type: 'string',
261
+ description: 'Session name',
262
+ default: 'default'
263
+ },
264
+ selector: {
265
+ type: 'string',
266
+ description: 'CSS selector to scroll into view (optional)'
267
+ },
268
+ x: {
269
+ type: 'integer',
270
+ description: 'Horizontal scroll amount in pixels',
271
+ default: 0
272
+ },
273
+ y: {
274
+ type: 'integer',
275
+ description: 'Vertical scroll amount in pixels',
276
+ default: 0
277
+ },
278
+ direction: {
279
+ type: 'string',
280
+ description: 'Scroll direction shortcut',
281
+ enum: %w[up down left right top bottom]
282
+ }
283
+ },
284
+ required: []
285
+ }
286
+ ) do |session: 'default', selector: nil, x: 0, y: 0, direction: nil, **|
287
+ page = sessions.page(session)
288
+
289
+ if selector
290
+ # Scroll element into view
291
+ element = page.at_css(selector)
292
+ raise ElementNotFoundError, "Element not found: #{selector}" unless element
293
+
294
+ element.scroll_into_view
295
+ MCP::Tool::Response.new([{ type: 'text', text: "Scrolled into view: #{selector}" }])
296
+ else
297
+ # Scroll by coordinates or direction
298
+ scroll_x, scroll_y = case direction
299
+ when 'up' then [0, -500]
300
+ when 'down' then [0, 500]
301
+ when 'left' then [-500, 0]
302
+ when 'right' then [500, 0]
303
+ when 'top' then [0, -100_000]
304
+ when 'bottom' then [0, 100_000]
305
+ else [x, y]
306
+ end
307
+
308
+ page.execute("window.scrollBy(#{scroll_x}, #{scroll_y})")
309
+ MCP::Tool::Response.new([{ type: 'text', text: "Scrolled by (#{scroll_x}, #{scroll_y})" }])
310
+ end
311
+ rescue ElementNotFoundError => e
312
+ MCP::Tool::Response.new([{ type: 'text', text: e.message }], error: true)
313
+ rescue Ferrum::Error => e
314
+ MCP::Tool::Response.new([{ type: 'text', text: "Scroll failed: #{e.message}" }], error: true)
315
+ end
316
+ end
317
+
318
+ def hover_tool(sessions)
319
+ MCP::Tool.define(
320
+ name: 'hover',
321
+ description: 'Hover over an element',
322
+ input_schema: {
323
+ type: 'object',
324
+ properties: {
325
+ session: {
326
+ type: 'string',
327
+ description: 'Session name',
328
+ default: 'default'
329
+ },
330
+ selector: {
331
+ type: 'string',
332
+ description: 'CSS selector for the element to hover'
333
+ }
334
+ },
335
+ required: ['selector']
336
+ }
337
+ ) do |selector:, session: 'default', **|
338
+ page = sessions.page(session)
339
+ element = page.at_css(selector)
340
+
341
+ raise ElementNotFoundError, "Element not found: #{selector}" unless element
342
+
343
+ element.hover
344
+
345
+ MCP::Tool::Response.new([{ type: 'text', text: "Hovering over: #{selector}" }])
346
+ rescue ElementNotFoundError => e
347
+ MCP::Tool::Response.new([{ type: 'text', text: e.message }], error: true)
348
+ rescue Ferrum::Error => e
349
+ MCP::Tool::Response.new([{ type: 'text', text: "Hover failed: #{e.message}" }], error: true)
350
+ end
351
+ end
352
+ end
353
+ end
354
+ end
355
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mcp'
4
+
5
+ module Crucible
6
+ module Tools
7
+ # Navigation tools: navigate, wait_for, back, forward, refresh
8
+ module Navigation
9
+ class << self
10
+ def tools(sessions, _config)
11
+ [
12
+ navigate_tool(sessions),
13
+ wait_for_tool(sessions),
14
+ back_tool(sessions),
15
+ forward_tool(sessions),
16
+ refresh_tool(sessions)
17
+ ]
18
+ end
19
+
20
+ private
21
+
22
+ def navigate_tool(sessions)
23
+ MCP::Tool.define(
24
+ name: 'navigate',
25
+ description: "Navigate browser to a URL. Creates a new session if it doesn't exist.",
26
+ input_schema: {
27
+ type: 'object',
28
+ properties: {
29
+ session: {
30
+ type: 'string',
31
+ description: 'Session name for managing multiple browsers',
32
+ default: 'default'
33
+ },
34
+ url: {
35
+ type: 'string',
36
+ description: 'URL to navigate to'
37
+ }
38
+ },
39
+ required: ['url']
40
+ }
41
+ ) do |url:, session: 'default', **|
42
+ page = sessions.page(session)
43
+ page.go_to(url)
44
+
45
+ MCP::Tool::Response.new(
46
+ [{
47
+ type: 'text',
48
+ text: "Navigated to #{url} (session: #{session})"
49
+ }]
50
+ )
51
+ rescue Ferrum::Error => e
52
+ MCP::Tool::Response.new([{ type: 'text', text: "Navigation failed: #{e.message}" }], error: true)
53
+ end
54
+ end
55
+
56
+ def wait_for_tool(sessions)
57
+ MCP::Tool.define(
58
+ name: 'wait_for',
59
+ description: 'Wait for an element to appear on the page',
60
+ input_schema: {
61
+ type: 'object',
62
+ properties: {
63
+ session: {
64
+ type: 'string',
65
+ description: 'Session name',
66
+ default: 'default'
67
+ },
68
+ selector: {
69
+ type: 'string',
70
+ description: 'CSS selector to wait for'
71
+ },
72
+ timeout: {
73
+ type: 'number',
74
+ description: 'Maximum wait time in seconds',
75
+ default: 30
76
+ }
77
+ },
78
+ required: ['selector']
79
+ }
80
+ ) do |selector:, session: 'default', timeout: 30, **|
81
+ page = sessions.page(session)
82
+
83
+ # Poll for element with timeout
84
+ start_time = Time.now
85
+ element = nil
86
+ loop do
87
+ element = page.at_css(selector)
88
+ break if element
89
+
90
+ if Time.now - start_time > timeout
91
+ return MCP::Tool::Response.new([{ type: 'text', text: "Timeout waiting for: #{selector}" }],
92
+ error: true)
93
+ end
94
+
95
+ sleep 0.1
96
+ end
97
+
98
+ MCP::Tool::Response.new([{ type: 'text', text: "Found element: #{selector}" }])
99
+ rescue Ferrum::Error => e
100
+ MCP::Tool::Response.new([{ type: 'text', text: "Wait failed: #{e.message}" }], error: true)
101
+ end
102
+ end
103
+
104
+ def back_tool(sessions)
105
+ MCP::Tool.define(
106
+ name: 'back',
107
+ description: 'Navigate back in browser history',
108
+ input_schema: {
109
+ type: 'object',
110
+ properties: {
111
+ session: {
112
+ type: 'string',
113
+ description: 'Session name',
114
+ default: 'default'
115
+ }
116
+ },
117
+ required: []
118
+ }
119
+ ) do |session: 'default', **|
120
+ page = sessions.page(session)
121
+ page.back
122
+
123
+ MCP::Tool::Response.new([{ type: 'text', text: 'Navigated back' }])
124
+ rescue Ferrum::Error => e
125
+ MCP::Tool::Response.new([{ type: 'text', text: "Back navigation failed: #{e.message}" }], error: true)
126
+ end
127
+ end
128
+
129
+ def forward_tool(sessions)
130
+ MCP::Tool.define(
131
+ name: 'forward',
132
+ description: 'Navigate forward in browser history',
133
+ input_schema: {
134
+ type: 'object',
135
+ properties: {
136
+ session: {
137
+ type: 'string',
138
+ description: 'Session name',
139
+ default: 'default'
140
+ }
141
+ },
142
+ required: []
143
+ }
144
+ ) do |session: 'default', **|
145
+ page = sessions.page(session)
146
+ page.forward
147
+
148
+ MCP::Tool::Response.new([{ type: 'text', text: 'Navigated forward' }])
149
+ rescue Ferrum::Error => e
150
+ MCP::Tool::Response.new([{ type: 'text', text: "Forward navigation failed: #{e.message}" }], error: true)
151
+ end
152
+ end
153
+
154
+ def refresh_tool(sessions)
155
+ MCP::Tool.define(
156
+ name: 'refresh',
157
+ description: 'Refresh the current page',
158
+ input_schema: {
159
+ type: 'object',
160
+ properties: {
161
+ session: {
162
+ type: 'string',
163
+ description: 'Session name',
164
+ default: 'default'
165
+ }
166
+ },
167
+ required: []
168
+ }
169
+ ) do |session: 'default', **|
170
+ page = sessions.page(session)
171
+ page.refresh
172
+
173
+ MCP::Tool::Response.new([{ type: 'text', text: 'Page refreshed' }])
174
+ rescue Ferrum::Error => e
175
+ MCP::Tool::Response.new([{ type: 'text', text: "Refresh failed: #{e.message}" }], error: true)
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mcp'
4
+ require 'json'
5
+
6
+ module Crucible
7
+ module Tools
8
+ # Session management tools: list_sessions, close_session
9
+ module Sessions
10
+ class << self
11
+ def tools(sessions, _config)
12
+ [
13
+ list_sessions_tool(sessions),
14
+ close_session_tool(sessions)
15
+ ]
16
+ end
17
+
18
+ private
19
+
20
+ def list_sessions_tool(sessions)
21
+ MCP::Tool.define(
22
+ name: 'list_sessions',
23
+ description: 'List all active browser sessions',
24
+ input_schema: {
25
+ type: 'object',
26
+ properties: {},
27
+ required: []
28
+ }
29
+ ) do |**|
30
+ session_list = sessions.list
31
+
32
+ if session_list.empty?
33
+ MCP::Tool::Response.new([{ type: 'text', text: 'No active sessions' }])
34
+ else
35
+ result = {
36
+ count: session_list.size,
37
+ sessions: session_list
38
+ }
39
+ MCP::Tool::Response.new([{ type: 'text', text: JSON.pretty_generate(result) }])
40
+ end
41
+ end
42
+ end
43
+
44
+ def close_session_tool(sessions)
45
+ MCP::Tool.define(
46
+ name: 'close_session',
47
+ description: 'Close a browser session and free resources',
48
+ input_schema: {
49
+ type: 'object',
50
+ properties: {
51
+ session: {
52
+ type: 'string',
53
+ description: 'Session name to close'
54
+ },
55
+ all: {
56
+ type: 'boolean',
57
+ description: 'Close all sessions',
58
+ default: false
59
+ }
60
+ },
61
+ required: []
62
+ }
63
+ ) do |session: nil, all: false, **|
64
+ if all
65
+ count = sessions.count
66
+ sessions.close_all
67
+ MCP::Tool::Response.new([{ type: 'text', text: "Closed #{count} session(s)" }])
68
+ elsif session
69
+ if sessions.close(session)
70
+ MCP::Tool::Response.new([{ type: 'text', text: "Closed session: #{session}" }])
71
+ else
72
+ MCP::Tool::Response.new([{ type: 'text', text: "Session not found: #{session}" }], error: true)
73
+ end
74
+ else
75
+ MCP::Tool::Response.new([{
76
+ type: 'text',
77
+ text: 'Please specify a session name or use all: true to close all sessions'
78
+ }], error: true)
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end