pwn 0.5.749 → 0.5.751

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +2 -2
  3. data/documentation/Blockchain.md +107 -9
  4. data/documentation/pwn-ai-Agent.md +310 -4
  5. data/documentation/pwn_silent_help_learn_demo.gif +0 -0
  6. data/etc/default_skills/pwn/banner/SKILL.md +7 -3
  7. data/etc/default_skills/pwn/blockchain/btc/SKILL.md +8 -5
  8. data/etc/default_skills/pwn/blockchain/eth/SKILL.md +12 -3
  9. data/etc/default_skills/pwn/blockchain/eth/references/urls.md +1 -1
  10. data/etc/default_skills/pwn/plugins/repl/SKILL.md +5 -0
  11. data/etc/default_skills/pwn/plugins/repl/ai/SKILL.md +4 -0
  12. data/etc/default_skills/pwn/plugins/repl/ai/references/urls.md +5 -0
  13. data/etc/default_skills/pwn/plugins/repl/ai_console/SKILL.md +48 -0
  14. data/etc/default_skills/pwn/plugins/repl/ai_console_commands/SKILL.md +47 -0
  15. data/etc/default_skills/pwn/plugins/repl/ai_console_usage/SKILL.md +48 -0
  16. data/etc/default_skills/pwn/plugins/repl/ai_swarm/SKILL.md +46 -0
  17. data/lib/pwn/ai/agent/loop.rb +287 -18
  18. data/lib/pwn/ai/agent/swarm.rb +28 -4
  19. data/lib/pwn/ai/open_ai.rb +4 -2
  20. data/lib/pwn/banner.rb +743 -2
  21. data/lib/pwn/blockchain/btc.rb +494 -196
  22. data/lib/pwn/blockchain/eth.rb +440 -81
  23. data/lib/pwn/blockchain.rb +6 -0
  24. data/lib/pwn/config.rb +22 -0
  25. data/lib/pwn/migrate.rb +9 -1
  26. data/lib/pwn/plugins/repl/ai.rb +182 -17
  27. data/lib/pwn/plugins/repl/ai_console.rb +1581 -0
  28. data/lib/pwn/plugins/repl/ai_console_commands.rb +282 -0
  29. data/lib/pwn/plugins/repl/ai_console_usage.rb +144 -0
  30. data/lib/pwn/plugins/repl/ai_swarm.rb +375 -0
  31. data/lib/pwn/plugins/repl.rb +25 -3
  32. data/lib/pwn/version.rb +1 -1
  33. data/spec/lib/pwn/ai/agent/loop_spec.rb +258 -1
  34. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +3 -2
  35. data/spec/lib/pwn/ai/open_ai_spec.rb +5 -0
  36. data/spec/lib/pwn/banner_spec.rb +643 -0
  37. data/spec/lib/pwn/blockchain/btc_spec.rb +372 -1
  38. data/spec/lib/pwn/blockchain/eth_spec.rb +205 -0
  39. data/spec/lib/pwn/blockchain_spec.rb +6 -0
  40. data/spec/lib/pwn/config_spec.rb +10 -0
  41. data/spec/lib/pwn/migrate_spec.rb +40 -3
  42. data/spec/lib/pwn/plugins/repl/ai_console_commands_spec.rb +65 -0
  43. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +1434 -0
  44. data/spec/lib/pwn/plugins/repl/ai_console_usage_spec.rb +29 -0
  45. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +127 -0
  46. data/spec/lib/pwn/plugins/repl_pwn_vault_spec.rb +5 -3
  47. data/spec/lib/pwn/plugins/repl_spec.rb +167 -1
  48. data/third_party/pwn_rdoc.jsonl +229 -10
  49. metadata +18 -6
  50. data/etc/default_skills/pwn/blockchain/btc/references/urls.md +0 -3
@@ -21,9 +21,7 @@ module PWN
21
21
  pi.config.pwn_ai_agent = true
22
22
  pi.config.color = false if pi.config.pwn_ai
23
23
 
24
- # Switch to custom multi-line input for pwn-ai (SHIFT+ENTER newline, ENTER submit)
25
- pi.config.input = PWNMultiLineInput.new(pi)
26
- PWN::Plugins::REPL.install_pwn_ai_completer!(pry: pi)
24
+ # Terminal ownership belongs to curses, not Reline.
27
25
 
28
26
  # Load and make aware of skills folder (scaled in PWN::Config per user pwn_env_path parent)
29
27
  skills_path = begin
@@ -46,6 +44,21 @@ module PWN
46
44
  pi.config.pwn_ai_session_id = sess[:id] if sess
47
45
  cron_count = (PWN.const_defined?(:Cron) ? PWN::Cron.list.keys.length : 0)
48
46
 
47
+ begin
48
+ console = PWN::Plugins::REPL::AIConsole.run(pry: pi)
49
+ rescue Exception # rubocop:disable Lint/RescueException -- restore Pry mode even on terminal interrupts
50
+ PWN::Plugins::REPL.leave_special_mode!(pry: pi)
51
+ raise
52
+ end
53
+ if console == :closed
54
+ PWN::Plugins::REPL.leave_special_mode!(pry: pi)
55
+ return
56
+ end
57
+
58
+ # Non-TTY/dumb terminal fallback keeps the existing line interface.
59
+ pi.config.input = PWNMultiLineInput.new(pi)
60
+ PWN::Plugins::REPL.install_pwn_ai_completer!(pry: pi)
61
+ puts '[*] Fullscreen curses unavailable on this terminal; using the legacy line interface.'
49
62
  puts '[*] pwn-ai agent TUI activated (PWN REPL driver w/ memory, sessions, delegation, cron).'
50
63
  puts "[*] Memory facts: #{mem_count} | Session: #{pi.config.pwn_ai_session_id} | Cron jobs: #{cron_count} | Skills: #{skills_count}"
51
64
  puts '[*] Instruct the AI agent to carry out a task, e.g.:'
@@ -53,6 +66,7 @@ module PWN
53
66
  puts " 'Execute CLI nmap -sV target.com and summarize findings using PWN modules.'"
54
67
  puts "[*] Skills loaded from #{skills_path} (#{skills_count} available) + memory/sessions/cron to expand autonomous capabilities."
55
68
  puts "[*] Type 'back' or CTRL+D to exit pwn-ai mode."
69
+ puts '[*] While busy: /steer <instruction> redirects the request; a running tool finishes first and is not undone. Trace ENTER stepping is suspended.'
56
70
  puts '[*] MULTILINE in pwn-ai: SHIFT+ENTER (or ALT+ENTER, or trailing `\\`) inserts a newline; ENTER submits to the AI.'
57
71
  puts '[*] TAB menus: leading `/` = commands (/cron /skills /sessions …); `/` later = host paths; otherwise Ruby completion (same as the pwn REPL).'
58
72
  puts "[*] tmux + terminator users: Ensure ~/.tmux.conf has 'set -s extended-keys on' and 'set -g xterm-keys on', then restart tmux. Use TERM=xterm-256color."
@@ -235,7 +249,7 @@ module PWN
235
249
  end
236
250
  end
237
251
  PWN_AI_SLASH_COMMANDS = %w[
238
- /back /cron /debug /delegate /help /learning /memory /mcp /model /sessions /skills /trace
252
+ /back /cron /debug /delegate /help /learning /memory /mcp /model /sessions /skills /steer /system-role /trace
239
253
  ].freeze
240
254
 
241
255
  PWN_AI_SLASH_SUBCOMMANDS = {
@@ -259,6 +273,19 @@ module PWN
259
273
  # 2. '/' anywhere else → :path (host-native file nav)
260
274
  # 3. else → :ruby (same Pry::InputCompleter menu as the pwn REPL)
261
275
  class << PWN::Plugins::REPL # rubocop:disable Metrics/ClassLength
276
+ # Foreground Loop plus one scoped terminal reader, not another Pry
277
+ # readline and not a worker thread running tools without their locals.
278
+ def pwn_ai_run_steerable(opts = {})
279
+ previous = Thread.current[:pwn_steering_input]
280
+ input = opts[:input] || $stdin
281
+ output = opts[:output] || $stdout
282
+ control = PWN::AI::Agent::Loop::Steering.new(input: input, output: output)
283
+ Thread.current[:pwn_steering_input] = control
284
+ control.with_reader { PWN::AI::Agent::Loop.run(opts.merge(steering: control)) }
285
+ ensure
286
+ Thread.current[:pwn_steering_input] = previous
287
+ end
288
+
262
289
  def pwn_ai_complete_kind(opts = {})
263
290
  line = opts[:line].to_s
264
291
  return :command if line.start_with?('/')
@@ -497,6 +524,8 @@ module PWN
497
524
  args = tokens[1..]
498
525
  pi = opts[:pry]
499
526
  case cmd
527
+ when '/steer'
528
+ puts(args.empty? ? '[pwn-ai] Usage: /steer <instruction>' : '[pwn-ai] /steer: no active request; submit a normal request instead')
500
529
  when '/help'
501
530
  puts 'pwn-ai commands:'
502
531
  PWN_AI_SLASH_COMMANDS.each do |c|
@@ -504,6 +533,7 @@ module PWN
504
533
  puts(subs.empty? ? " #{c}" : " #{c} #{subs.join('|')}")
505
534
  end
506
535
  puts ' TAB: /… command menu · slash later in the line: path nav · else Ruby completion'
536
+ puts ' /steer <instruction>: redirect an active request; a running tool finishes first (not undone)'
507
537
  when '/back'
508
538
  if pi.respond_to?(:eval)
509
539
  pi.eval('back')
@@ -535,6 +565,8 @@ module PWN
535
565
  puts ' Use agent_list / agent_debate from pwn-ai, or pwn-ai-delegate in the pwn REPL.'
536
566
  when '/model'
537
567
  pwn_ai_run_model(args: args)
568
+ when '/system-role'
569
+ puts '[pwn-ai] /system-role requires the curses console. Open pwn-ai in an interactive terminal.'
538
570
  when '/mcp'
539
571
  pwn_ai_run_mcp(args: args)
540
572
  when '/learning'
@@ -625,6 +657,8 @@ module PWN
625
657
  sub = args[0].to_s
626
658
  if sub.empty? || %w[show status].include?(sub)
627
659
  msg = "active=#{current.empty? ? '(none)' : current} model=#{current_model.empty? ? '(unset)' : current_model}"
660
+ effort = PWN::Env.dig(:ai, current.to_sym, :reasoning_effort)
661
+ msg += " reasoning_effort=#{effort}" unless effort.to_s.empty?
628
662
  puts "[*] #{msg}"
629
663
  return msg
630
664
  end
@@ -634,6 +668,7 @@ module PWN
634
668
  puts 'pwn-ai /model — switch provider and model in this session'
635
669
  puts " current: #{current} #{current_model}"
636
670
  puts ' usage: /model [list] | /model list llms | /model <engine> [model] | /model <model>'
671
+ puts ' Supported models prompt for reasoning effort before applying; Enter accepts, Esc cancels.'
637
672
  engines.each do |eng|
638
673
  mark = eng == current ? '*' : ' '
639
674
  puts " #{mark} #{eng} #{pwn_ai_engine_model(engine: eng)}"
@@ -654,18 +689,108 @@ module PWN
654
689
  raise "no active engine — /model <engine> first (#{engines.join(', ')})" if engine.to_s.empty?
655
690
  raise "unknown engine #{engine.inspect} — try: #{engines.join(', ')}" unless engines.include?(engine.to_s)
656
691
 
692
+ selection = pwn_ai_reasoning_selection(engine: engine, model: model || pwn_ai_engine_model(engine: engine))
693
+ prompt = opts[:prompt]
694
+ if selection[:efforts].any?
695
+ effort = prompt.respond_to?(:call) ? prompt.call(selection) : pwn_ai_prompt_reasoning(selection: selection)
696
+ return 'Model selection cancelled.' if effort.nil?
697
+ return selection if effort == :deferred
698
+
699
+ raise ArgumentError, 'unsupported reasoning effort' unless selection[:efforts].include?(effort)
700
+
701
+ selection[:reasoning_effort] = effort
702
+ end
703
+ pwn_ai_apply_model(selection: selection)
704
+ end
705
+
706
+ # Resolve capabilities only on submitted selections, never completion.
707
+ def pwn_ai_reasoning_selection(opts = {})
708
+ engine = opts[:engine].to_s
709
+ model = opts[:model].to_s
710
+ efforts = []
711
+ default = 'medium'
712
+ if engine == 'openai' && PWN::AI::OpenAI.send(:reasoning_model?, model: model)
713
+ begin
714
+ catalog = PWN::AI::OpenAI.get_models
715
+ rows = catalog[:data] || catalog[:models] || []
716
+ row = Array(rows).find { |item| item.is_a?(Hash) && (item[:id] || item[:slug] || item['id'] || item['slug']) == model } || {}
717
+ levels = row[:supported_reasoning_levels] || row['supported_reasoning_levels']
718
+ efforts = Array(levels).filter_map { |level| level.is_a?(Hash) ? (level[:effort] || level['effort']) : nil }.map(&:to_s)
719
+ default = row[:default_reasoning_level] || row['default_reasoning_level'] || default
720
+ rescue StandardError => e
721
+ warn "[pwn-ai] reasoning catalog unavailable: #{e.class}; using documented capabilities."
722
+ end
723
+ # Model-specific API contracts; do not guess future families.
724
+ # https://developers.openai.com/api/docs/models/gpt-6-astra
725
+ # https://developers.openai.com/api/docs/models/gpt-5
726
+ efforts = %w[low medium high xhigh max] if efforts.empty? && model == 'gpt-6-astra'
727
+ efforts = %w[minimal low medium high] if efforts.empty? && model.match?(/\Agpt-5(?:-\d{4}-\d{2}-\d{2})?\z/)
728
+ # Same model pages document these explicit versions (not codex/pro variants).
729
+ efforts = %w[none low medium high] if efforts.empty? && model.match?(/\Agpt-5\.1(?:-\d{4}-\d{2}-\d{2})?\z/)
730
+ efforts = %w[none low medium high xhigh] if efforts.empty? && model.match?(/\Agpt-5\.(?:2|4|5)(?:-\d{4}-\d{2}-\d{2})?\z/)
731
+ elsif engine == 'grok'
732
+ # https://docs.x.ai/developers/model-capabilities/text/reasoning
733
+ efforts = %w[low medium high xhigh] if %w[grok-4.6 grok-4.7].include?(model)
734
+ efforts = %w[low medium high] if model == 'grok-4.5'
735
+ efforts = %w[low high] if %w[grok-3-mini grok-3-mini-fast].include?(model)
736
+ end
737
+ current = PWN::Env.dig(:ai, engine.to_sym, :reasoning_effort).to_s
738
+ efforts.delete('none') if engine == 'openai' && model.start_with?('gpt-6-astra')
739
+ default = current if efforts.include?(current)
740
+ default = efforts.first unless efforts.include?(default)
741
+ { engine: engine, model: model, efforts: efforts.uniq, default: default }
742
+ end
743
+
744
+ def pwn_ai_prompt_reasoning(opts = {})
745
+ selection = opts[:selection]
746
+ # The legacy line interface alone owns stdin here. Curses supplies a callback.
747
+ return selection[:default] unless $stdin.tty?
748
+
749
+ loop do
750
+ puts "Reasoning effort for #{selection[:engine]}/#{selection[:model]}: #{selection[:efforts].join(', ')}"
751
+ print "Effort [#{selection[:default]}] (Enter accepts; q/Esc cancels): "
752
+ line = $stdin.gets
753
+ return nil if line.nil? || ["\e", 'q', 'cancel'].include?(line.strip)
754
+
755
+ value = line.strip.empty? ? selection[:default] : line.strip
756
+ return value if selection[:efforts].include?(value)
757
+ end
758
+ rescue Interrupt
759
+ nil
760
+ end
761
+
762
+ def pwn_ai_apply_model(opts = {})
763
+ selection = opts[:selection]
764
+ engine = selection[:engine]
765
+ model = selection[:model]
657
766
  PWN::Env[:ai] ||= {}
658
767
  PWN::Env[:ai][engine.to_sym] ||= {}
659
768
  PWN::Env[:ai][:active] = engine.to_s
660
769
  PWN::Env[:ai][engine.to_sym][:model] = model unless model.to_s.strip.empty?
661
- persisted = persist_ai_selection(engine: engine, model: PWN::Env[:ai][engine.to_sym][:model])
770
+ persistence = { engine: engine, model: PWN::Env[:ai][engine.to_sym][:model] }
771
+ if selection.key?(:reasoning_effort)
772
+ PWN::Env[:ai][engine.to_sym][:reasoning_effort] = selection[:reasoning_effort]
773
+ persistence[:reasoning_effort] = selection[:reasoning_effort]
774
+ end
775
+ persisted = persist_ai_selection(persistence)
662
776
  shown = PWN::Env[:ai][engine.to_sym][:model]
663
777
  msg = "active=#{engine} model=#{shown.to_s.empty? ? '(unset)' : shown}"
778
+ msg += " reasoning_effort=#{selection[:reasoning_effort]}" if selection.key?(:reasoning_effort)
664
779
  msg = "#{msg} (session only)" unless persisted
665
780
  puts "[*] #{msg}"
666
781
  msg
667
782
  end
668
783
 
784
+ def pwn_ai_apply_system_role(opts = {})
785
+ engine = opts[:engine].to_s
786
+ content = opts[:content].to_s
787
+ raise 'Active engine changed; reopen /system-role.' unless engine == PWN::Env.dig(:ai, :active).to_s && PWN::Env.dig(:ai, engine.to_sym).is_a?(Hash)
788
+ raise 'System role not saved: vault persistence failed (check vault/decryptor permissions).' unless persist_ai_selection(engine: engine, system_role_content: content)
789
+
790
+ PWN::Env[:ai][engine.to_sym][:system_role_content] = content
791
+ :saved
792
+ end
793
+
669
794
  def persist_ai_selection(opts = {})
670
795
  engine = opts[:engine].to_s
671
796
  model = opts[:model]
@@ -688,26 +813,41 @@ module PWN
688
813
  iv = decryptor.is_a?(Hash) ? decryptor[:iv] : nil
689
814
  return false if key.to_s.strip.empty? || iv.to_s.strip.empty?
690
815
 
691
- PWN::Plugins::Vault.decrypt(file: env_path, key: key, iv: iv)
692
- begin
693
- cfg = YAML.load_file(env_path, symbolize_names: true)
816
+ # Never decrypt the live vault in place: failed encryption must leave
817
+ # the previous ciphertext intact. Rename only the encrypted merge.
818
+ require 'tempfile'
819
+ Tempfile.create(['.pwn-ai-selection', '.yaml'], File.dirname(env_path)) do |file|
820
+ file.binmode
821
+ file.write(File.binread(env_path))
822
+ file.flush
823
+ path = file.path
824
+ PWN::Plugins::Vault.decrypt(file: path, key: key, iv: iv)
825
+ cfg = YAML.load_file(path, symbolize_names: true)
694
826
  cfg = {} unless cfg.is_a?(Hash)
695
827
  cfg[:ai] = {} unless cfg[:ai].is_a?(Hash)
696
- cfg[:ai][:active] = engine
828
+ cfg[:ai][:active] = engine unless opts.key?(:system_role_content)
697
829
  unless model.to_s.strip.empty?
698
830
  slot = engine.to_sym
699
831
  cfg[:ai][slot] = {} unless cfg[:ai][slot].is_a?(Hash)
700
832
  cfg[:ai][slot][:model] = model
701
833
  end
834
+ if opts.key?(:reasoning_effort)
835
+ cfg[:ai][engine.to_sym] ||= {}
836
+ cfg[:ai][engine.to_sym][:reasoning_effort] = opts[:reasoning_effort]
837
+ end
838
+ if opts.key?(:system_role_content)
839
+ cfg[:ai][engine.to_sym] ||= {}
840
+ cfg[:ai][engine.to_sym][:system_role_content] = opts[:system_role_content]
841
+ end
702
842
  yaml_env = YAML.dump(cfg).gsub(/^(\s*):/, '\1')
703
- File.write(env_path, yaml_env)
704
- File.chmod(0o600, env_path)
705
- ensure
706
- PWN::Plugins::Vault.encrypt(file: env_path, key: key, iv: iv)
843
+ File.write(path, yaml_env)
844
+ File.chmod(0o600, path)
845
+ PWN::Plugins::Vault.encrypt(file: path, key: key, iv: iv)
846
+ File.rename(path, env_path)
707
847
  end
708
848
  true
709
849
  rescue StandardError => e
710
- warn "[pwn-ai] /model persist skipped: #{e.class}: #{e.message}"
850
+ warn "[pwn-ai] settings persist failed: #{e.class}: #{e.message}"
711
851
  false
712
852
  end
713
853
 
@@ -1006,13 +1146,38 @@ module PWN
1006
1146
 
1007
1147
  # Apply /model arguments to the active engine.
1008
1148
  #{self}.pwn_ai_run_model(
1009
- args: 'optional - Array of slash tokens after /model'
1149
+ args: 'optional - Array of slash tokens after /model',
1150
+ prompt: 'optional - callable receiving selection metadata; return effort, nil to cancel, or :deferred'
1151
+ )
1152
+
1153
+ # Resolve model-specific reasoning options from provider capabilities.
1154
+ #{self}.pwn_ai_reasoning_selection(
1155
+ engine: 'required - provider name',
1156
+ model: 'required - exact model identifier'
1157
+ )
1158
+
1159
+ # Ask for effort in the legacy line interface only.
1160
+ #{self}.pwn_ai_prompt_reasoning(
1161
+ selection: 'required - model selection with efforts and default'
1162
+ )
1163
+
1164
+ # Apply an accepted model selection and merge its vault fields.
1165
+ #{self}.pwn_ai_apply_model(
1166
+ selection: 'required - engine, model and optional reasoning_effort'
1167
+ )
1168
+
1169
+ # Save a role to the vault before updating the active engine.
1170
+ #{self}.pwn_ai_apply_system_role(
1171
+ engine: 'required - active engine name captured when opening the editor',
1172
+ content: 'required - exact multiline system role content, including an empty string'
1010
1173
  )
1011
1174
 
1012
- # Persist engine and model into the encrypted pwn.yaml vault.
1175
+ # Persist engine settings into the encrypted pwn.yaml vault.
1013
1176
  #{self}.persist_ai_selection(
1014
1177
  engine: 'required - engine name to store as ai.active',
1015
- model: 'required - model id to store for that engine'
1178
+ model: 'optional - model id to store for that engine',
1179
+ reasoning_effort: 'optional - accepted effort to merge into that engine only',
1180
+ system_role_content: 'optional - exact accepted role to merge into that engine only'
1016
1181
  )
1017
1182
 
1018
1183
  # Run /cron locally without Loop.run.