pwn 0.5.729 → 0.5.732

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.
@@ -0,0 +1,480 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+ require 'timeout'
6
+
7
+ module PWN
8
+ module AI
9
+ module MCP
10
+ # JSON-RPC 2.0 / MCP 2024-11-05 stdio client for combo.nation.
11
+ # Speaks the native menu-oriented tools over newline-delimited JSON;
12
+ # it does not scrape a terminal or enable hardware unless requested.
13
+ module ComboNation
14
+ PROTOCOL_VERSION = '2024-11-05'
15
+ DEFAULT_COMMAND = '/opt/combo.nation/combo.nation'
16
+ TOOLS = %w[
17
+ menu_catalog menu_main menu_dial_calibration menu_guess
18
+ hardware_connect operation_status operation_answer operation_cancel servo_pause
19
+ ].freeze
20
+
21
+ public_class_method def self.required_bins
22
+ %w[combo.nation]
23
+ end
24
+
25
+ # Build the combo.nation MCP argv. Hardware remains off unless requested.
26
+
27
+ public_class_method def self.argv(opts = {})
28
+ command = (opts[:command] || DEFAULT_COMMAND).to_s
29
+ raise ArgumentError, 'command is required' if command.empty?
30
+
31
+ argv = [command, '--mcp']
32
+ Array(opts[:args]).each { |arg| argv << arg.to_s }
33
+ argv << '--mcp-allow-hardware' if opts[:allow_hardware]
34
+ argv
35
+ end
36
+
37
+ # Spawn or attach to combo.nation --mcp and complete initialize.
38
+
39
+ public_class_method def self.connect(opts = {})
40
+ timeout = (opts[:timeout] || 5).to_f
41
+ allow_hardware = opts[:allow_hardware] ? true : false
42
+ reader = opts[:reader]
43
+ writer = opts[:writer]
44
+ stdin = stdout = stderr = wait_thr = nil
45
+ unless reader && writer
46
+ command = argv(opts)
47
+ begin
48
+ stdin, stdout, stderr, wait_thr = Open3.popen3(*command)
49
+ rescue Errno::ENOENT
50
+ raise IOError, "combo.nation MCP executable not found: #{command.first}. Build combo.nation or pass command: to #{self}.connect."
51
+ end
52
+ reader = stdout
53
+ writer = stdin
54
+ end
55
+ session = {
56
+ reader: reader,
57
+ writer: writer,
58
+ stdin: stdin,
59
+ stdout: stdout,
60
+ stderr: stderr,
61
+ wait_thr: wait_thr,
62
+ mutex: Mutex.new,
63
+ next_id: 1,
64
+ timeout: timeout,
65
+ allow_hardware: allow_hardware,
66
+ stderr_buf: +''
67
+ }
68
+ drain_stderr(session: session)
69
+ begin
70
+ result = request(
71
+ session: session,
72
+ method: 'initialize',
73
+ params: {
74
+ 'protocolVersion' => PROTOCOL_VERSION,
75
+ 'capabilities' => {},
76
+ 'clientInfo' => { 'name' => 'pwn', 'version' => PWN::VERSION.to_s }
77
+ }
78
+ )
79
+ notify(session: session, method: 'notifications/initialized')
80
+ session[:protocol_version] = result['protocolVersion']
81
+ session[:server_info] = result['serverInfo']
82
+ session
83
+ rescue StandardError
84
+ disconnect(session: session)
85
+ raise
86
+ end
87
+ end
88
+
89
+ # Close the MCP stdio session and reap a spawned combo.nation process.
90
+
91
+ public_class_method def self.disconnect(opts = {})
92
+ session = opts[:session]
93
+ return unless session.is_a?(Hash)
94
+
95
+ close_io(io: session[:writer])
96
+ if session[:wait_thr]
97
+ begin
98
+ Timeout.timeout(session[:timeout] || 5) { session[:wait_thr].value }
99
+ rescue Timeout::Error
100
+ begin
101
+ Process.kill('TERM', session[:wait_thr].pid)
102
+ rescue StandardError
103
+ nil
104
+ end
105
+ begin
106
+ session[:wait_thr].value
107
+ rescue StandardError
108
+ nil
109
+ end
110
+ end
111
+ end
112
+ close_io(io: session[:reader])
113
+ close_io(io: session[:stderr])
114
+ session[:stderr_thread]&.join(1)
115
+ session
116
+ end
117
+
118
+ # Send a JSON-RPC request and return its result object.
119
+
120
+ public_class_method def self.request(opts = {})
121
+ session = session!(opts)
122
+ method = opts[:method].to_s
123
+ raise ArgumentError, 'method is required' if method.empty?
124
+
125
+ params = opts[:params]
126
+ timeout = (opts[:timeout] || session[:timeout] || 5).to_f
127
+ session[:mutex].synchronize do
128
+ ident = session[:next_id]
129
+ session[:next_id] += 1
130
+ message = { 'jsonrpc' => '2.0', 'id' => ident, 'method' => method }
131
+ message['params'] = params unless params.nil?
132
+ write_line(session: session, message: message)
133
+ response = read_json(session: session, timeout: timeout)
134
+ raise IOError, 'combo.nation MCP closed stdout' if response.nil?
135
+ raise IOError, 'combo.nation MCP response id mismatch' unless response['id'] == ident
136
+
137
+ if response['error']
138
+ error = response['error']
139
+ raise IOError, "combo.nation MCP error #{error['code']}: #{error['message']}"
140
+ end
141
+ response['result']
142
+ end
143
+ end
144
+
145
+ # Send a JSON-RPC notification with no response.
146
+
147
+ public_class_method def self.notify(opts = {})
148
+ session = session!(opts)
149
+ method = opts[:method].to_s
150
+ raise ArgumentError, 'method is required' if method.empty?
151
+
152
+ message = { 'jsonrpc' => '2.0', 'method' => method }
153
+ message['params'] = opts[:params] unless opts[:params].nil?
154
+ session[:mutex].synchronize { write_line(session: session, message: message) }
155
+ method
156
+ end
157
+
158
+ # MCP ping.
159
+
160
+ public_class_method def self.ping(opts = {})
161
+ request(session: opts[:session], method: 'ping')
162
+ end
163
+
164
+ # MCP tools/list.
165
+
166
+ public_class_method def self.list_tools(opts = {})
167
+ result = request(session: opts[:session], method: 'tools/list')
168
+ Array(result && result['tools'])
169
+ end
170
+
171
+ # MCP tools/call with JSON text payloads parsed when possible.
172
+
173
+ public_class_method def self.call_tool(opts = {})
174
+ name = opts[:name].to_s
175
+ raise ArgumentError, 'name is required' if name.empty?
176
+
177
+ arguments = opts[:arguments]
178
+ arguments = {} if arguments.nil?
179
+ raise ArgumentError, 'arguments must be a Hash' unless arguments.is_a?(Hash)
180
+
181
+ result = request(
182
+ session: opts[:session],
183
+ method: 'tools/call',
184
+ params: { 'name' => name, 'arguments' => stringify(value: arguments) }
185
+ )
186
+ parse_tool_result(result: result)
187
+ end
188
+
189
+ # List actual main, dial calibration, and guess menus with string IDs.
190
+
191
+ public_class_method def self.menu_catalog(opts = {})
192
+ call_tool(session: opts[:session], name: 'menu_catalog')
193
+ end
194
+
195
+ # Select a main-menu option by exact string ID.
196
+
197
+ public_class_method def self.menu_main(opts = {})
198
+ menu_call(session: opts[:session], name: 'menu_main', option: opts[:option])
199
+ end
200
+
201
+ # Select a dial-calibration option by exact string ID.
202
+
203
+ public_class_method def self.menu_dial_calibration(opts = {})
204
+ menu_call(session: opts[:session], name: 'menu_dial_calibration', option: opts[:option])
205
+ end
206
+
207
+ # Select a guess-menu option by exact string ID such as 5.10.
208
+
209
+ public_class_method def self.menu_guess(opts = {})
210
+ menu_call(session: opts[:session], name: 'menu_guess', option: opts[:option])
211
+ end
212
+
213
+ # Start hardware initialization; requires --mcp-allow-hardware on the server.
214
+
215
+ public_class_method def self.hardware_connect(opts = {})
216
+ call_tool(session: opts[:session], name: 'hardware_connect')
217
+ end
218
+
219
+ # Read the current operation, pending prompt, and hardware flags.
220
+
221
+ public_class_method def self.operation_status(opts = {})
222
+ call_tool(session: opts[:session], name: 'operation_status')
223
+ end
224
+
225
+ # Answer the pending prompt by id.
226
+
227
+ public_class_method def self.operation_answer(opts = {})
228
+ prompt_id = opts[:prompt_id]
229
+ raise ArgumentError, 'prompt_id is required' if prompt_id.nil?
230
+ raise ArgumentError, 'value is required' unless opts.key?(:value)
231
+
232
+ call_tool(
233
+ session: opts[:session],
234
+ name: 'operation_answer',
235
+ arguments: { 'prompt_id' => prompt_id, 'value' => opts[:value] }
236
+ )
237
+ end
238
+
239
+ # Request cooperative cancellation of the active operation.
240
+
241
+ public_class_method def self.operation_cancel(opts = {})
242
+ call_tool(session: opts[:session], name: 'operation_cancel')
243
+ end
244
+
245
+ # Pause or resume the current servo move.
246
+
247
+ public_class_method def self.servo_pause(opts = {})
248
+ action = opts[:action].to_s
249
+ raise ArgumentError, 'action is required' unless %w[pause resume].include?(action)
250
+
251
+ call_tool(session: opts[:session], name: 'servo_pause', arguments: { 'action' => action })
252
+ end
253
+
254
+ # Poll operation_status until a terminal or requested state.
255
+
256
+ public_class_method def self.poll_until(opts = {})
257
+ session = opts[:session]
258
+ wanted = Array(opts[:states] || %w[completed failed cancelled]).map(&:to_s)
259
+ interval = (opts[:interval] || 1).to_f
260
+ timeout = (opts[:timeout] || 30).to_f
261
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
262
+ loop do
263
+ status = operation_status(session: session)
264
+ state = status.dig(:parsed, 'state').to_s
265
+ return status if wanted.include?(state)
266
+
267
+ raise Timeout::Error, "combo.nation operation still #{state.inspect}" if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
268
+
269
+ sleep interval if interval.positive?
270
+ end
271
+ end
272
+
273
+ # Author(s):: 0day Inc. <support@0dayinc.com>
274
+
275
+ public_class_method def self.authors
276
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
277
+ end
278
+
279
+ public_class_method def self.help
280
+ puts "USAGE:
281
+ # List host binaries this module expects to be installed.
282
+ #{self}.required_bins
283
+
284
+ # Build combo.nation MCP argv without launching a process.
285
+ #{self}.argv(
286
+ command: 'optional - combo.nation executable path (default #{DEFAULT_COMMAND})',
287
+ args: 'optional - extra argv after --mcp',
288
+ allow_hardware: 'optional - append --mcp-allow-hardware (default false)'
289
+ )
290
+
291
+ # Spawn or attach to combo.nation --mcp and complete MCP initialize.
292
+ #{self}.connect(
293
+ command: 'optional - combo.nation executable path (default #{DEFAULT_COMMAND})',
294
+ args: 'optional - extra argv after --mcp',
295
+ allow_hardware: 'optional - pass --mcp-allow-hardware (default false; never implied)',
296
+ timeout: 'optional - seconds for handshake and subsequent reads (default 5)',
297
+ reader: 'optional - IO providing server stdout for tests',
298
+ writer: 'optional - IO consuming server stdin for tests'
299
+ )
300
+
301
+ # Close pipes and reap a spawned combo.nation process.
302
+ #{self}.disconnect(
303
+ session: 'required - Hash returned by connect'
304
+ )
305
+
306
+ # Send a JSON-RPC 2.0 request and return its result object.
307
+ #{self}.request(
308
+ session: 'required - Hash returned by connect',
309
+ method: 'required - JSON-RPC method name such as ping or tools/list',
310
+ params: 'optional - Hash or Array params object',
311
+ timeout: 'optional - seconds to wait for this response'
312
+ )
313
+
314
+ # Send a JSON-RPC notification that expects no response.
315
+ #{self}.notify(
316
+ session: 'required - Hash returned by connect',
317
+ method: 'required - notification method such as notifications/initialized',
318
+ params: 'optional - Hash params object'
319
+ )
320
+
321
+ # Send an MCP ping request.
322
+ #{self}.ping(
323
+ session: 'required - Hash returned by connect'
324
+ )
325
+
326
+ # List tools advertised by the combo.nation MCP server.
327
+ #{self}.list_tools(
328
+ session: 'required - Hash returned by connect'
329
+ )
330
+
331
+ # MCP tools/call; JSON text content is parsed when valid.
332
+ #{self}.call_tool(
333
+ session: 'required - Hash returned by connect',
334
+ name: 'required - tool name advertised by tools/list',
335
+ arguments: 'optional - Hash of tool arguments (string menu IDs stay strings)'
336
+ )
337
+
338
+ # List actual application menus and exact string option IDs.
339
+ #{self}.menu_catalog(
340
+ session: 'required - Hash returned by connect'
341
+ )
342
+
343
+ # Select a main-menu option by exact string ID.
344
+ #{self}.menu_main(
345
+ session: 'required - Hash returned by connect',
346
+ option: 'required - exact menu ID string such as 0.3'
347
+ )
348
+
349
+ # Select a dial-calibration option by exact string ID.
350
+ #{self}.menu_dial_calibration(
351
+ session: 'required - Hash returned by connect',
352
+ option: 'required - exact menu ID string such as 3.99'
353
+ )
354
+
355
+ # Select a guess-menu option by exact string ID.
356
+ #{self}.menu_guess(
357
+ session: 'required - Hash returned by connect',
358
+ option: 'required - exact menu ID string such as 5.10'
359
+ )
360
+
361
+ # Start hardware initialization; the server still requires --mcp-allow-hardware.
362
+ #{self}.hardware_connect(
363
+ session: 'required - Hash returned by connect'
364
+ )
365
+
366
+ # Read the current operation, pending prompt, updates, and hardware flags.
367
+ #{self}.operation_status(
368
+ session: 'required - Hash returned by connect'
369
+ )
370
+
371
+ # Answer the pending prompt by id.
372
+ #{self}.operation_answer(
373
+ session: 'required - Hash returned by connect',
374
+ prompt_id: 'required - integer prompt id from operation_status',
375
+ value: 'required - number, exact option string, rotation plan, or true for ack'
376
+ )
377
+
378
+ # Request cooperative cancellation of the active operation.
379
+ #{self}.operation_cancel(
380
+ session: 'required - Hash returned by connect'
381
+ )
382
+
383
+ # Pause or resume the current servo move.
384
+ #{self}.servo_pause(
385
+ session: 'required - Hash returned by connect',
386
+ action: 'required - pause or resume'
387
+ )
388
+
389
+ # Poll operation_status until a requested state; the first reply is not completion.
390
+ #{self}.poll_until(
391
+ session: 'required - Hash returned by connect',
392
+ states: 'optional - Array of state strings (default completed failed cancelled)',
393
+ interval: 'optional - seconds between polls (default 1)',
394
+ timeout: 'optional - seconds before Timeout::Error (default 30)'
395
+ )
396
+
397
+ # Print the AUTHOR(S) string for this module.
398
+ #{self}.authors
399
+ "
400
+ end
401
+
402
+ private_class_method def self.session!(opts = {})
403
+ session = opts[:session]
404
+ raise ArgumentError, 'session is required' unless session.is_a?(Hash) && session[:reader] && session[:writer]
405
+
406
+ session
407
+ end
408
+
409
+ private_class_method def self.menu_call(opts = {})
410
+ option = opts[:option].to_s
411
+ raise ArgumentError, 'option is required' if option.empty?
412
+
413
+ call_tool(session: opts[:session], name: opts[:name], arguments: { 'option' => option })
414
+ end
415
+
416
+ private_class_method def self.write_line(opts = {})
417
+ session = opts[:session]
418
+ session[:writer].write("#{JSON.generate(opts[:message])}\n")
419
+ session[:writer].flush
420
+ end
421
+
422
+ private_class_method def self.read_json(opts = {})
423
+ session = opts[:session]
424
+ line = Timeout.timeout(opts[:timeout]) { session[:reader].gets }
425
+ return if line.nil?
426
+
427
+ JSON.parse(line)
428
+ end
429
+
430
+ private_class_method def self.parse_tool_result(opts = {})
431
+ result = opts[:result] || {}
432
+ text = result.dig('content', 0, 'text')
433
+ parsed = begin
434
+ JSON.parse(text)
435
+ rescue JSON::ParserError, TypeError
436
+ text
437
+ end
438
+ {
439
+ content: result['content'],
440
+ is_error: result['isError'] == true,
441
+ text: text,
442
+ parsed: parsed
443
+ }
444
+ end
445
+
446
+ private_class_method def self.stringify(opts = {})
447
+ value = opts[:value]
448
+ case value
449
+ when Hash
450
+ value.to_h { |key, child| [key.to_s, stringify(value: child)] }
451
+ when Array
452
+ value.map { |child| stringify(value: child) }
453
+ else
454
+ value
455
+ end
456
+ end
457
+
458
+ private_class_method def self.drain_stderr(opts = {})
459
+ session = opts[:session]
460
+ stderr = session[:stderr]
461
+ return unless stderr
462
+
463
+ session[:stderr_thread] = Thread.new do
464
+ Thread.current.report_on_exception = false
465
+ stderr.each { |chunk| session[:stderr_buf] << chunk }
466
+ rescue IOError, Errno::EPIPE
467
+ nil
468
+ end
469
+ end
470
+
471
+ private_class_method def self.close_io(opts = {})
472
+ io = opts[:io]
473
+ io.close unless io.nil? || io.closed?
474
+ rescue IOError
475
+ nil
476
+ end
477
+ end
478
+ end
479
+ end
480
+ end