pwn 0.5.721 → 0.5.723

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 (108) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +2 -2
  3. data/bin/pwn_setup +5 -5
  4. data/documentation/AI-Integration.md +34 -1
  5. data/documentation/Policy-Benchmark.md +151 -0
  6. data/documentation/Reinforcement-Learning.md +27 -1
  7. data/etc/default_skills/pwn/ai/agent/engagement/SKILL.md +1 -0
  8. data/etc/default_skills/pwn/ai/agent/learning/SKILL.md +2 -0
  9. data/etc/default_skills/pwn/ai/agent/metrics/SKILL.md +4 -0
  10. data/etc/default_skills/pwn/ai/agent/mistakes/SKILL.md +2 -0
  11. data/etc/default_skills/pwn/ai/agent/policy/SKILL.md +1 -0
  12. data/etc/default_skills/pwn/ai/agent/reward/SKILL.md +2 -0
  13. data/etc/default_skills/pwn/ai/agent/swarm/SKILL.md +6 -0
  14. data/etc/default_skills/pwn/ai/agent/tools/capabilities/SKILL.md +45 -0
  15. data/etc/default_skills/pwn/ai/agent/tools/context/SKILL.md +45 -0
  16. data/etc/default_skills/pwn/ai/context/SKILL.md +50 -0
  17. data/etc/default_skills/pwn/ai/http_retry/SKILL.md +7 -0
  18. data/etc/default_skills/pwn/ai/http_retry/references/urls.md +4 -0
  19. data/etc/default_skills/pwn/ai/open_ai/SKILL.md +1 -0
  20. data/etc/default_skills/pwn/ai/open_ai/references/urls.md +1 -0
  21. data/etc/default_skills/pwn/plugins/exploit_dev/SKILL.md +1 -0
  22. data/etc/default_skills/pwn/plugins/findings/SKILL.md +1 -0
  23. data/etc/default_skills/pwn/plugins/gdb/SKILL.md +1 -0
  24. data/etc/default_skills/pwn/plugins/gdbmi/SKILL.md +55 -0
  25. data/etc/default_skills/pwn/plugins/ghidra_headless/SKILL.md +49 -0
  26. data/etc/default_skills/pwn/plugins/jobs/SKILL.md +6 -0
  27. data/etc/default_skills/pwn/plugins/packet/SKILL.md +3 -0
  28. data/etc/default_skills/pwn/plugins/preflight_checker/SKILL.md +1 -0
  29. data/etc/default_skills/pwn/plugins/radare2/SKILL.md +1 -0
  30. data/etc/default_skills/pwn/plugins/transparent_browser/SKILL.md +3 -0
  31. data/etc/default_skills/pwn/reports/engagement/SKILL.md +3 -2
  32. data/lib/pwn/ai/agent/curriculum.rb +37 -45
  33. data/lib/pwn/ai/agent/dispatch.rb +9 -0
  34. data/lib/pwn/ai/agent/engagement.rb +59 -0
  35. data/lib/pwn/ai/agent/learning.rb +111 -44
  36. data/lib/pwn/ai/agent/loop.rb +50 -8
  37. data/lib/pwn/ai/agent/metrics.rb +70 -2
  38. data/lib/pwn/ai/agent/mistakes.rb +67 -1
  39. data/lib/pwn/ai/agent/policy.rb +142 -16
  40. data/lib/pwn/ai/agent/registry.rb +1 -1
  41. data/lib/pwn/ai/agent/reward.rb +153 -53
  42. data/lib/pwn/ai/agent/swarm.rb +235 -35
  43. data/lib/pwn/ai/agent/tool_guard.rb +13 -1
  44. data/lib/pwn/ai/agent/tools/artifacts.rb +50 -3
  45. data/lib/pwn/ai/agent/tools/capabilities.rb +19 -0
  46. data/lib/pwn/ai/agent/tools/context.rb +38 -0
  47. data/lib/pwn/ai/agent/tools/finding_record.rb +18 -0
  48. data/lib/pwn/ai/agent/tools/fuzz_campaign.rb +10 -1
  49. data/lib/pwn/ai/agent/tools/job_run.rb +32 -0
  50. data/lib/pwn/ai/agent/tools/learning.rb +5 -6
  51. data/lib/pwn/ai/agent/tools/metrics.rb +16 -0
  52. data/lib/pwn/ai/agent/tools/pty_session.rb +4 -4
  53. data/lib/pwn/ai/agent/tools/ruby_eval.rb +6 -5
  54. data/lib/pwn/ai/agent/tools/shell.rb +10 -1
  55. data/lib/pwn/ai/agent/tools/skills.rb +30 -0
  56. data/lib/pwn/ai/agent/tools/swarm.rb +8 -2
  57. data/lib/pwn/ai/context.rb +193 -0
  58. data/lib/pwn/ai/http_retry.rb +53 -7
  59. data/lib/pwn/ai/open_ai.rb +302 -45
  60. data/lib/pwn/ai.rb +1 -0
  61. data/lib/pwn/migrate.rb +10 -1
  62. data/lib/pwn/plugins/artifact_registry.rb +40 -17
  63. data/lib/pwn/plugins/binary_parser.rb +4 -1
  64. data/lib/pwn/plugins/exploit_dev.rb +15 -0
  65. data/lib/pwn/plugins/findings.rb +48 -8
  66. data/lib/pwn/plugins/gdb.rb +17 -0
  67. data/lib/pwn/plugins/gdbmi.rb +128 -0
  68. data/lib/pwn/plugins/ghidra_headless.rb +104 -0
  69. data/lib/pwn/plugins/jobs.rb +72 -0
  70. data/lib/pwn/plugins/packet.rb +51 -0
  71. data/lib/pwn/plugins/preflight_checker.rb +29 -0
  72. data/lib/pwn/plugins/process_tube.rb +24 -7
  73. data/lib/pwn/plugins/radare2.rb +14 -2
  74. data/lib/pwn/plugins/repl.rb +6 -1
  75. data/lib/pwn/plugins/transparent_browser.rb +64 -0
  76. data/lib/pwn/plugins.rb +2 -0
  77. data/lib/pwn/reports/engagement.rb +19 -0
  78. data/lib/pwn/sessions.rb +3 -1
  79. data/lib/pwn/version.rb +1 -1
  80. data/scripts/benchmark_policy.rb +276 -0
  81. data/spec/documentation/installation_md_spec.rb +18 -4
  82. data/spec/integration/reinforced_feedback_loop_spec.rb +20 -13
  83. data/spec/lib/pwn/ai/agent/curriculum_spec.rb +267 -0
  84. data/spec/lib/pwn/ai/agent/engagement_spec.rb +12 -0
  85. data/spec/lib/pwn/ai/agent/learning_spec.rb +81 -3
  86. data/spec/lib/pwn/ai/agent/loop_spec.rb +51 -0
  87. data/spec/lib/pwn/ai/agent/metrics_spec.rb +44 -0
  88. data/spec/lib/pwn/ai/agent/mistakes_spec.rb +10 -0
  89. data/spec/lib/pwn/ai/agent/policy_spec.rb +103 -0
  90. data/spec/lib/pwn/ai/agent/registry_spec.rb +62 -0
  91. data/spec/lib/pwn/ai/agent/reward_spec.rb +175 -12
  92. data/spec/lib/pwn/ai/agent/swarm_spec.rb +121 -1
  93. data/spec/lib/pwn/ai/agent/tool_guard_spec.rb +6 -0
  94. data/spec/lib/pwn/ai/agent/tools/capabilities_spec.rb +14 -0
  95. data/spec/lib/pwn/ai/agent/tools/context_spec.rb +14 -0
  96. data/spec/lib/pwn/ai/agent/tools/job_run_spec.rb +2 -0
  97. data/spec/lib/pwn/ai/agent/tools/learning_spec.rb +25 -0
  98. data/spec/lib/pwn/ai/context_spec.rb +48 -0
  99. data/spec/lib/pwn/ai/http_retry_spec.rb +27 -0
  100. data/spec/lib/pwn/ai/open_ai_oauth_transport_spec.rb +245 -0
  101. data/spec/lib/pwn/ai/open_ai_spec.rb +150 -0
  102. data/spec/lib/pwn/migrate_spec.rb +24 -0
  103. data/spec/lib/pwn/plugins/artifact_registry_spec.rb +19 -0
  104. data/spec/lib/pwn/plugins/findings_spec.rb +2 -0
  105. data/spec/lib/pwn/plugins/gdbmi_spec.rb +17 -0
  106. data/spec/lib/pwn/plugins/ghidra_headless_spec.rb +17 -0
  107. data/third_party/pwn_rdoc.jsonl +86 -2
  108. metadata +24 -5
@@ -5,6 +5,7 @@ require 'base64'
5
5
  require 'securerandom'
6
6
  require 'digest'
7
7
  require 'uri'
8
+ require 'tempfile'
8
9
 
9
10
  module PWN
10
11
  module AI
@@ -34,8 +35,8 @@ module PWN
34
35
  # token response -- no localhost listener)
35
36
  # * refresh_token grant at /oauth/token (JSON body, same as codex)
36
37
  #
37
- # Access tokens are short-lived JWTs. Persist refresh_token via
38
- # pwn-vault under ai.openai.oauth.refresh_token.
38
+ # Access tokens are short-lived JWTs. Enrollment and refresh update
39
+ # ai.openai.oauth in the live environment and existing encrypted vault.
39
40
  # ------------------------------------------------------------------
40
41
  OPENAI_OAUTH_ISSUER = 'https://auth.openai.com'
41
42
  OPENAI_OAUTH_TOKEN_URI = "#{OPENAI_OAUTH_ISSUER}/oauth/token".freeze
@@ -197,9 +198,14 @@ module PWN
197
198
  return false
198
199
  end
199
200
 
200
- PWN::Plugins::Vault.decrypt(file: env_path, key: key, iv: iv)
201
- begin
202
- cfg = YAML.load_file(env_path, symbolize_names: true)
201
+ # Work on a private sibling; never decrypt the live vault in place.
202
+ # Rename only after encryption succeeds, preserving the old vault on
203
+ # write/encryption errors and keeping the existing key + iv intact.
204
+ Tempfile.create(['.pwn-openai-oauth-', '.yaml'], File.dirname(env_path)) do |temp|
205
+ temp.write(File.binread(env_path))
206
+ temp.flush
207
+ PWN::Plugins::Vault.decrypt(file: temp.path, key: key, iv: iv)
208
+ cfg = YAML.load_file(temp.path, symbolize_names: true)
203
209
  cfg = {} unless cfg.is_a?(Hash)
204
210
  cfg[:ai] = {} unless cfg[:ai].is_a?(Hash)
205
211
  cfg[:ai][:openai] = {} unless cfg[:ai][:openai].is_a?(Hash)
@@ -214,16 +220,15 @@ module PWN
214
220
 
215
221
  # Match PWN::Config.default_env YAML style (string keys, no leading ':').
216
222
  yaml_env = YAML.dump(cfg).gsub(/^(\s*):/, '\1')
217
- File.write(env_path, yaml_env)
218
- File.chmod(0o600, env_path)
219
- ensure
220
- # Always re-encrypt with the IDENTICAL key + iv — never rotate.
221
- PWN::Plugins::Vault.encrypt(file: env_path, key: key, iv: iv)
223
+ File.write(temp.path, yaml_env)
224
+ PWN::Plugins::Vault.encrypt(file: temp.path, key: key, iv: iv)
225
+ temp.fsync
226
+ File.rename(temp.path, env_path)
222
227
  end
223
228
 
224
229
  true
225
230
  rescue StandardError => e
226
- warn "[!] OpenAI OAuth vault persistence failed (session tokens still updated): #{e.class}: #{e.message}"
231
+ warn "[!] OpenAI OAuth vault persistence failed (session tokens still updated): #{e.class}"
227
232
  false
228
233
  end
229
234
 
@@ -239,6 +244,8 @@ module PWN
239
244
  # 2. User opens https://auth.openai.com/codex/device and enters code
240
245
  # 3. Poll POST /api/accounts/deviceauth/token until authorization_code + pkce
241
246
  # 4. POST /oauth/token authorization_code grant -> access/refresh/id tokens
247
+ # Success syncs standalone calls into the live Env and persists tokens
248
+ # using existing vault decryption artifacts, or reports session-only use.
242
249
  public_class_method def self.obtain_oauth_bearer_token(opts = {})
243
250
  client_id = real_config_value?(value: opts[:client_id]) ? opts[:client_id] : OPENAI_OAUTH_CLIENT_ID
244
251
  issuer = real_config_value?(value: opts[:issuer]) ? opts[:issuer].to_s.sub(%r{/*\z}, '') : OPENAI_OAUTH_ISSUER
@@ -356,16 +363,11 @@ module PWN
356
363
  end
357
364
  end
358
365
 
359
- puts "\n[*] SUCCESS: OpenAI / ChatGPT OAuth bearer obtained via device_code grant."
360
- puts ' Cached in-memory for this pwn / pwn-ai process.'
361
- puts ''
362
- puts ' TO MAKE THIS PERMANENT (recommended -- one-time), store via pwn-vault:'
363
- puts " ai.openai.oauth.refresh_token = #{refresh_token}" if refresh_token
364
- puts " ai.openai.oauth.bearer_token = #{access_token}"
365
- puts " ai.openai.oauth.account_id = #{opts[:account_id]}" if opts[:account_id]
366
- puts ' On future runs the refresh_token alone is enough -- PWN::AI::OpenAI will'
367
- puts ' silently exchange it for a fresh access_token (no browser, no prompt).'
368
- puts ''
366
+ sync_oauth_into_env(oauth: opts)
367
+ persisted = persist_oauth_to_vault(oauth: opts)
368
+
369
+ puts "\n[*] SUCCESS: OpenAI / ChatGPT OAuth enrollment completed."
370
+ puts(persisted ? ' Tokens saved to the encrypted vault; future sessions can refresh automatically.' : ' Tokens available in this session only; encrypted vault persistence was unavailable.')
369
371
 
370
372
  access_token
371
373
  rescue RestClient::ExceptionWithResponse => e
@@ -420,6 +422,8 @@ module PWN
420
422
 
421
423
  token = obtain_oauth_bearer_token(oauth) if token.nil? && (oauth_opt_in || !real_config_value?(value: engine[:key])) && !opts[:non_interactive]
422
424
 
425
+ # Route by the credential actually selected, not configured OAuth state.
426
+ oauth_selected = !token.nil?
423
427
  token = engine[:key] if token.nil? && real_config_value?(value: engine[:key])
424
428
 
425
429
  if token.nil?
@@ -438,7 +442,7 @@ module PWN
438
442
  opts[:http_method].to_s.scrub.to_sym
439
443
  end
440
444
 
441
- base_uri = real_config_value?(value: engine[:base_uri]) ? engine[:base_uri] : 'https://api.openai.com/v1'
445
+ base_uri = transport_base_uri(base_uri: engine[:base_uri], oauth_selected: oauth_selected)
442
446
  rest_call = opts[:rest_call].to_s.scrub
443
447
  params = opts[:params]
444
448
  headers = {
@@ -446,10 +450,19 @@ module PWN
446
450
  authorization: "Bearer #{token}"
447
451
  }
448
452
  # ChatGPT subscription tokens often need the account id header (codex).
449
- headers['ChatGPT-Account-Id'] = oauth[:account_id] if real_config_value?(value: oauth[:account_id])
453
+ headers['ChatGPT-Account-Id'] = oauth[:account_id] if oauth_selected && real_config_value?(value: oauth[:account_id])
450
454
 
451
455
  http_body = opts[:http_body]
452
456
  http_body ||= {}
457
+ oauth_responses = oauth_selected && %w[chat/completions responses].include?(rest_call)
458
+ if oauth_responses
459
+ rest_call = 'responses'
460
+ http_body = oauth_responses_body(http_body: http_body)
461
+ headers[:accept] = 'text/event-stream'
462
+ elsif http_body[:messages].is_a?(Array)
463
+ # Native Responses items are local history, not Chat Completions fields.
464
+ http_body = http_body.merge(messages: http_body[:messages].map { |msg| msg.except(:_native_content, '_native_content') })
465
+ end
453
466
 
454
467
  timeout = PWN::AI::HttpRetry.timeout_s(opts)
455
468
  max_attempts = PWN::AI::HttpRetry.max_attempts(opts)
@@ -470,7 +483,8 @@ module PWN
470
483
  method: http_method,
471
484
  url: "#{base_uri}/#{rest_call}",
472
485
  headers: headers,
473
- verify_ssl: false,
486
+ verify_ssl: oauth_selected,
487
+ max_redirects: oauth_selected ? 0 : 10,
474
488
  timeout: timeout
475
489
  )
476
490
 
@@ -483,7 +497,8 @@ module PWN
483
497
  url: "#{base_uri}/#{rest_call}",
484
498
  headers: headers,
485
499
  payload: http_body,
486
- verify_ssl: false,
500
+ verify_ssl: oauth_selected,
501
+ max_redirects: oauth_selected ? 0 : 10,
487
502
  timeout: timeout
488
503
  )
489
504
  else
@@ -492,7 +507,8 @@ module PWN
492
507
  url: "#{base_uri}/#{rest_call}",
493
508
  headers: headers,
494
509
  payload: http_body.to_json,
495
- verify_ssl: false,
510
+ verify_ssl: oauth_selected,
511
+ max_redirects: oauth_selected ? 0 : 10,
496
512
  timeout: timeout
497
513
  )
498
514
  end
@@ -500,20 +516,26 @@ module PWN
500
516
  else
501
517
  raise @@logger.error("Unsupported HTTP Method #{http_method} for #{self} Plugin")
502
518
  end
503
- response
519
+ oauth_responses ? parse_responses(raw: decode_responses_stream(response: response)).to_json : response
504
520
  rescue RestClient::TooManyRequests => e
505
521
  retry_count += 1
506
- if retry_count >= max_attempts
507
- unless opts[:quiet]
508
- PWN::AI::HttpRetry.report_event(
509
- label: 'openai', which_self: self, quiet: opts[:quiet],
510
- http_method: http_method, rest_call: rest_call,
511
- extra: '429 retries exhausted', error: e
512
- )
513
- end
514
- return "#{e.message}: #{e.response}"
515
- end
516
- sleep(PWN::AI::HttpRetry.retry_after_s(response: e.response, retry_count: retry_count) + rand(0.3..5.0))
522
+ body = e.response.to_s[0, 400]
523
+ quota = PWN::AI::HttpRetry.quota_exhausted?(error: e)
524
+ extra = if quota
525
+ "quota exhausted body=#{body}"
526
+ elsif retry_count >= max_attempts
527
+ "429 retries exhausted body=#{body}"
528
+ else
529
+ "429 attempt=#{retry_count}/#{max_attempts} body=#{body}"
530
+ end
531
+ PWN::AI::HttpRetry.report_event(
532
+ label: 'openai', which_self: self, quiet: opts[:quiet],
533
+ http_method: http_method, rest_call: rest_call,
534
+ extra: extra, error: e
535
+ )
536
+ raise e if quota || retry_count >= max_attempts
537
+
538
+ sleep(PWN::AI::HttpRetry.retry_after_s(response: e.response, retry_count: retry_count) + rand(0.3..1.5))
517
539
  retry
518
540
  rescue RestClient::Exceptions::Timeout => e
519
541
  # Sidecar hops pass quiet:true. Never print
@@ -528,12 +550,18 @@ module PWN
528
550
  end
529
551
  retry if retry_count < max_attempts
530
552
 
553
+ raise e if oauth_selected
554
+
531
555
  nil
532
556
  end
533
557
  rescue RestClient::ExceptionWithResponse => e
558
+ raise e if oauth_selected || e.is_a?(RestClient::TooManyRequests)
559
+
534
560
  puts "ERROR: #{e.message}: #{e.response}" unless opts[:quiet]
535
561
  "#{e.message}: #{e.response}" if opts[:quiet]
536
562
  rescue StandardError => e
563
+ raise e if oauth_selected
564
+
537
565
  case e.message
538
566
  when '400 Bad Request', '404 Resource Not Found'
539
567
  nil
@@ -579,7 +607,14 @@ module PWN
579
607
  raise 'ERROR: messages array is required' if messages.nil? || messages.empty?
580
608
 
581
609
  # OpenAI rejects Hash function.arguments / Hash content (422 map → string).
582
- messages = PWN::AI::Agent::Loop.openai_wire_messages(messages: messages) if defined?(PWN::AI::Agent::Loop) && PWN::AI::Agent::Loop.respond_to?(:openai_wire_messages)
610
+ if defined?(PWN::AI::Agent::Loop) && PWN::AI::Agent::Loop.respond_to?(:openai_wire_messages)
611
+ originals = messages.grep(Hash)
612
+ messages = PWN::AI::Agent::Loop.openai_wire_messages(messages: messages)
613
+ messages.each_with_index do |msg, index|
614
+ native = originals[index][:_native_content] || originals[index]['_native_content']
615
+ msg[:_native_content] = native if native.is_a?(Array)
616
+ end
617
+ end
583
618
 
584
619
  model = opts[:model] ||= engine[:model]
585
620
 
@@ -602,6 +637,8 @@ module PWN
602
637
  model: model,
603
638
  messages: messages
604
639
  }
640
+ max_tokens = (engine[:max_tokens] || engine[:max_completion_tokens] || 16_384).to_i
641
+ http_body[:max_completion_tokens] = max_tokens if max_tokens.positive?
605
642
  http_body[:prompt_cache_key] = cache_key if cache_key
606
643
  unless reasoning
607
644
  temp = opts[:temp].to_f
@@ -611,9 +648,21 @@ module PWN
611
648
  http_body[:tools] = opts[:tools] if opts[:tools] && !opts[:tools].empty?
612
649
  http_body[:tool_choice] = opts[:tool_choice] if opts[:tool_choice]
613
650
 
651
+ endpoint = api_endpoint(model: model, tools: opts[:tools])
652
+ if endpoint == 'responses'
653
+ http_body = responses_http_body(
654
+ model: model,
655
+ messages: messages,
656
+ tools: opts[:tools],
657
+ tool_choice: opts[:tool_choice],
658
+ max_tokens: max_tokens,
659
+ reasoning_effort: opts[:reasoning_effort] || engine[:reasoning_effort]
660
+ )
661
+ end
662
+
614
663
  response = open_ai_rest_call(
615
664
  http_method: :post,
616
- rest_call: 'chat/completions',
665
+ rest_call: endpoint,
617
666
  http_body: http_body,
618
667
  timeout: opts[:timeout],
619
668
  spinner: opts[:spinner],
@@ -622,18 +671,218 @@ module PWN
622
671
  return nil if response.nil?
623
672
 
624
673
  json_resp = JSON.parse(response, symbolize_names: true)
625
- json_resp[:assistant_message] = json_resp.dig(:choices, 0, :message)
674
+ json_resp = parse_responses(raw: json_resp) if endpoint == 'responses'
675
+ json_resp[:assistant_message] ||= json_resp.dig(:choices, 0, :message)
626
676
  json_resp
627
677
  rescue StandardError => e
628
678
  raise e
629
679
  end
630
680
 
681
+ public_class_method def self.api_endpoint(opts = {})
682
+ model = opts[:model].to_s.downcase
683
+ tools = opts[:tools]
684
+ return 'responses' if responses_api?(model: model, tools: tools)
685
+
686
+ 'chat/completions'
687
+ end
688
+
631
689
  # OpenAI reasoning-family models (o1 / o3 / o4 / gpt-5 reasoning) reject
632
690
  # `temperature`, `top_p`, etc. and use role 'developer' in place of
633
691
  # 'system'. Detect by prefix so future minor revisions still match.
634
692
  private_class_method def self.reasoning_model?(opts = {})
635
693
  m = opts[:model].to_s.downcase
636
- m.start_with?('o1', 'o3', 'o4', 'o5') || m.include?('reason')
694
+ m.start_with?('o1', 'o3', 'o4', 'o5', 'gpt-5', 'gpt-6') || m.include?('reason') || m.include?('astra')
695
+ end
696
+
697
+ private_class_method def self.responses_api?(opts = {})
698
+ m = opts[:model].to_s.downcase
699
+ return true if m.start_with?('gpt-6') || m.include?('astra')
700
+ return true if m.include?('codex')
701
+
702
+ if m.match?(/\Agpt-5\.(\d+)/)
703
+ minor = m[/\Agpt-5\.(\d+)/, 1].to_i
704
+ return true if minor >= 4
705
+ end
706
+
707
+ false
708
+ end
709
+
710
+ private_class_method def self.responses_http_body(opts = {})
711
+ model = opts[:model]
712
+ max_tokens = opts[:max_tokens].to_i
713
+ instructions = []
714
+ input = []
715
+ Array(opts[:messages]).each do |msg|
716
+ role = (msg[:role] || msg['role']).to_s
717
+ content = msg[:content] || msg['content']
718
+ case role
719
+ when 'system', 'developer'
720
+ instructions << content.to_s unless content.to_s.empty?
721
+ when 'user'
722
+ input << { role: 'user', content: content }
723
+ when 'assistant'
724
+ native = msg[:_native_content] || msg['_native_content']
725
+ if native.is_a?(Array) && native.any?
726
+ input.concat(native)
727
+ else
728
+ Array(msg[:tool_calls] || msg['tool_calls']).each do |tc|
729
+ fn = tc[:function] || tc['function'] || {}
730
+ input << {
731
+ type: 'function_call',
732
+ call_id: tc[:id] || tc['id'],
733
+ name: fn[:name] || fn['name'] || tc[:name],
734
+ arguments: (fn[:arguments] || fn['arguments'] || tc[:arguments]).to_s
735
+ }
736
+ end
737
+ input << { role: 'assistant', content: content } unless content.to_s.empty?
738
+ end
739
+ when 'tool'
740
+ input << {
741
+ type: 'function_call_output',
742
+ call_id: msg[:tool_call_id] || msg['tool_call_id'],
743
+ output: content.to_s
744
+ }
745
+ else
746
+ input << { role: role, content: content } unless content.to_s.empty?
747
+ end
748
+ end
749
+
750
+ body = {
751
+ model: model,
752
+ input: input
753
+ }
754
+ body[:instructions] = instructions.join("\n") unless instructions.empty?
755
+ body[:max_output_tokens] = max_tokens if max_tokens.positive?
756
+ rtools = responses_tools(tools: opts[:tools])
757
+ body[:tools] = rtools unless rtools.empty?
758
+ tc = opts[:tool_choice]
759
+ if tc.is_a?(Hash)
760
+ fn = tc[:function] || tc['function'] || tc
761
+ name = fn[:name] || fn['name']
762
+ body[:tool_choice] = { type: 'function', name: name } if name
763
+ elsif !tc.to_s.empty?
764
+ body[:tool_choice] = tc
765
+ end
766
+ effort = opts[:reasoning_effort].to_s
767
+ body[:reasoning] = { effort: effort } if !effort.empty? && effort != 'none'
768
+ body
769
+ end
770
+
771
+ # Built-in endpoints are auth-specific; explicit compatible proxies remain
772
+ # operator-controlled. Never send subscription credentials over plain HTTP.
773
+ private_class_method def self.transport_base_uri(opts = {})
774
+ oauth_selected = opts[:oauth_selected]
775
+ default = oauth_selected ? 'https://chatgpt.com/backend-api/codex' : 'https://api.openai.com/v1'
776
+ return default unless real_config_value?(value: opts[:base_uri])
777
+
778
+ base_uri = opts[:base_uri].to_s.strip.sub(%r{/+\z}, '')
779
+ uri = URI.parse(base_uri)
780
+ return default if %w[api.openai.com chatgpt.com chat.openai.com].include?(uri.host.to_s.downcase)
781
+
782
+ raise ArgumentError, 'OpenAI OAuth custom endpoints require HTTPS' if oauth_selected && uri.scheme != 'https'
783
+
784
+ base_uri
785
+ end
786
+
787
+ # Match openai/codex core/src/client.rs and codex-api/src/common.rs:
788
+ # subscription requests stream, do not store, and omit sampling/token caps.
789
+ private_class_method def self.oauth_responses_body(opts = {})
790
+ body = opts[:http_body].dup
791
+ if body.key?(:messages)
792
+ body = responses_http_body(
793
+ model: body[:model], messages: body[:messages], tools: body[:tools],
794
+ tool_choice: body[:tool_choice], reasoning_effort: body[:reasoning_effort]
795
+ )
796
+ end
797
+ %i[temperature top_p max_tokens max_completion_tokens max_output_tokens].each { |key| body.delete(key) }
798
+ body[:include] = (Array(body[:include]) + ['reasoning.encrypted_content']).uniq
799
+ body.merge(instructions: body[:instructions].to_s, store: false, stream: true)
800
+ end
801
+
802
+ # RestClient buffers the SSE body; only a completed response is usable.
803
+ private_class_method def self.decode_responses_stream(opts = {})
804
+ output = {}
805
+ # SSE dispatches only blank-line-terminated frames, never a partial EOF.
806
+ opts[:response].to_s.split(/\r?\n\r?\n/, -1)[0...-1].each do |frame|
807
+ data = frame.lines.filter_map { |line| line.sub(/\Adata: ?/, '').strip if line.start_with?('data:') }.join("\n")
808
+ next if data.empty? || data == '[DONE]'
809
+
810
+ event = JSON.parse(data, symbolize_names: true)
811
+ output[event[:output_index]] = event[:item] if event[:type] == 'response.output_item.done' && event[:item].is_a?(Hash)
812
+ response = event[:response]
813
+ response = {} unless response.is_a?(Hash)
814
+ if %w[error response.failed response.incomplete].include?(event[:type]) || response[:error] ||
815
+ (response[:status] && event[:type] == 'response.completed' && response[:status] != 'completed')
816
+ detail = response.dig(:error, :message) || event.dig(:error, :message) ||
817
+ response.dig(:incomplete_details, :reason) || event[:message] || response[:status]
818
+ raise "OpenAI #{event[:type]}: #{detail}"
819
+ end
820
+ next unless event[:type] == 'response.completed'
821
+
822
+ raise 'OpenAI response.completed missing response' if response.empty?
823
+
824
+ response[:output] ||= output.sort_by { |index, _| index.to_i }.map(&:last)
825
+ return response
826
+ end
827
+ raise 'OpenAI response stream closed before response.completed'
828
+ end
829
+
830
+ private_class_method def self.responses_tools(opts = {})
831
+ Array(opts[:tools]).filter_map do |tool|
832
+ fn = tool[:function] || tool['function'] || tool
833
+ name = fn[:name] || fn['name'] || tool[:name] || tool['name']
834
+ next if name.to_s.empty?
835
+
836
+ {
837
+ type: 'function',
838
+ name: name,
839
+ description: fn[:description] || fn['description'] || tool[:description],
840
+ parameters: fn[:parameters] || fn['parameters'] || { type: 'object', properties: {} },
841
+ strict: false
842
+ }
843
+ end
844
+ end
845
+
846
+ private_class_method def self.parse_responses(opts = {})
847
+ raw = opts[:raw]
848
+ raw = {} unless raw.is_a?(Hash)
849
+ output = Array(raw[:output] || raw['output'])
850
+ text = (raw[:output_text] || raw['output_text']).to_s
851
+ text_parts = []
852
+ tool_calls = []
853
+ output.each do |item|
854
+ next unless item.is_a?(Hash)
855
+
856
+ type = (item[:type] || item['type']).to_s
857
+ if type == 'function_call'
858
+ tool_calls << {
859
+ id: item[:call_id] || item['call_id'] || item[:id] || item['id'],
860
+ type: 'function',
861
+ function: {
862
+ name: item[:name] || item['name'],
863
+ arguments: (item[:arguments] || item['arguments']).to_s
864
+ }
865
+ }
866
+ elsif type == 'message' && text.empty?
867
+ Array(item[:content] || item['content']).each do |part|
868
+ next unless part.is_a?(Hash)
869
+
870
+ t = part[:text] || part['text']
871
+ text_parts << t.to_s if (part[:type] || part['type']).to_s.include?('text') && !t.to_s.empty?
872
+ end
873
+ end
874
+ end
875
+ text = text_parts.join if text.empty?
876
+ msg = {
877
+ role: 'assistant',
878
+ content: text.empty? ? nil : text,
879
+ tool_calls: tool_calls,
880
+ _native_content: output
881
+ }
882
+ raw.merge(
883
+ assistant_message: msg,
884
+ choices: [{ message: msg }]
885
+ )
637
886
  end
638
887
 
639
888
  private_class_method def self.remap_system_to_developer(opts = {})
@@ -1190,7 +1439,7 @@ module PWN
1190
1439
 
1191
1440
  public_class_method def self.help
1192
1441
  puts "USAGE:
1193
- # Run refresh oauth bearer token and return its result
1442
+ # Refresh OAuth credentials and save them to the existing encrypted vault.
1194
1443
  #{self}.refresh_oauth_bearer_token(
1195
1444
  refresh_token: 'required - OpenAI/ChatGPT OAuth refresh_token',
1196
1445
  client_id: 'optional - defaults to Codex public client',
@@ -1201,7 +1450,8 @@ module PWN
1201
1450
  account_id: 'optional - account id value consumed by #refresh_oauth_bearer_token'
1202
1451
  )
1203
1452
 
1204
- # Run obtain oauth bearer token and return its result
1453
+ # Enroll via device consent; cache and persist tokens without printing them.
1454
+ # Returns the bearer: append '; nil' in a console to suppress its echo.
1205
1455
  #{self}.obtain_oauth_bearer_token(
1206
1456
  client_id: 'optional - Codex public client id',
1207
1457
  issuer: 'optional - defaults to https://auth.openai.com',
@@ -1217,6 +1467,12 @@ module PWN
1217
1467
  # Run get models and return its result
1218
1468
  #{self}.get_models
1219
1469
 
1470
+ # Chat Completions vs Responses path for this model (tools may force Responses).
1471
+ #{self}.api_endpoint(
1472
+ model: 'required - OpenAI model id (e.g. gpt-6-astra or gpt-4o)',
1473
+ tools: 'optional - tools array; gpt-6 and gpt-5.4+ with tools use responses'
1474
+ )
1475
+
1220
1476
  # Run chat with tools and return its result
1221
1477
  #{self}.chat_with_tools(
1222
1478
  messages: 'required - full OpenAI-format messages array (system/user/assistant/tool)',
@@ -1226,7 +1482,8 @@ module PWN
1226
1482
  temp: 'optional - temperature (defaults to PWN::Env[:ai][:openai][:temp] || 1)',
1227
1483
  timeout: 'optional - seconds (default 900)',
1228
1484
  spinner: 'optional - display spinner (default false)',
1229
- quiet: 'optional - quiet value consumed by #chat_with_tools'
1485
+ quiet: 'optional - quiet value consumed by #chat_with_tools',
1486
+ reasoning_effort: 'optional - Responses reasoning.effort (never none on gpt-6-astra)'
1230
1487
  )
1231
1488
 
1232
1489
  # Run chat and return its result
data/lib/pwn/ai.rb CHANGED
@@ -6,6 +6,7 @@ module PWN
6
6
  # http://www.rubyinside.com/ruby-techniques-revealed-autoload-1652.html
7
7
  module AI
8
8
  autoload :Agent, 'pwn/ai/agent'
9
+ autoload :Context, 'pwn/ai/context'
9
10
  autoload :Anthropic, 'pwn/ai/anthropic'
10
11
  autoload :Gemini, 'pwn/ai/gemini'
11
12
  autoload :Grok, 'pwn/ai/grok'
data/lib/pwn/migrate.rb CHANGED
@@ -49,7 +49,7 @@ module PWN
49
49
  # Bump this whenever the shape of any file under ~/.pwn changes in a
50
50
  # way that requires a one-time transform. Add the transform as an
51
51
  # entry in MIGRATIONS keyed by the NEW schema number.
52
- SCHEMA_VERSION = 1
52
+ SCHEMA_VERSION = 2
53
53
 
54
54
  OK = "\e[32mok\e[0m"
55
55
  BAD = "\e[31mFAIL\e[0m"
@@ -212,6 +212,15 @@ module PWN
212
212
  io.puts " · migrate_legacy_skills → #{r[:migrated]} converted" if r[:migrated].to_i.positive?
213
213
  PWN::Config.install_default_skills if defined?(PWN::Config)
214
214
  PWN::Cron.install_defaults if defined?(PWN::Cron)
215
+ },
216
+ 2 => lambda { |root, io|
217
+ path = File.join(root, 'agents.yml')
218
+ r = if defined?(PWN::AI::Agent::Swarm) && PWN::AI::Agent::Swarm.respond_to?(:migrate_personas)
219
+ PWN::AI::Agent::Swarm.migrate_personas(path: path)
220
+ else
221
+ { changed: false }
222
+ end
223
+ io.puts " · agents.yml #{Array(r[:patched]).join(', ')}" if r[:changed]
215
224
  }
216
225
  }.freeze
217
226
 
@@ -43,21 +43,39 @@ module PWN
43
43
  end
44
44
 
45
45
  public_class_method def self.get(opts = {})
46
- path = opts[:path].to_s
47
- raise 'ERROR: path is required' if path.empty?
46
+ sha = opts[:sha256].to_s
47
+ unless sha.empty?
48
+ dest = File.join(ROOT, 'sha256', sha[0, 2], sha)
49
+ path = dest if File.file?(dest)
50
+ end
51
+ path ||= opts[:path].to_s
52
+ raise 'ERROR: path is required' if path.to_s.empty?
48
53
  raise "ERROR: file not found: #{path}" unless File.file?(path)
49
54
 
50
55
  { path: path, sha256: Digest::SHA256.file(path).hexdigest, bytes: File.size(path), body: File.binread(path)[0, 65_536] }
51
56
  end
52
57
 
53
58
  public_class_method def self.read_page(opts = {})
54
- path = opts[:path].to_s
59
+ path = (opts[:path] || opts[:ref]).to_s
55
60
  raise 'ERROR: path is required' if path.empty?
56
61
  raise "ERROR: file not found: #{path}" unless File.file?(path)
57
62
 
63
+ raise 'ERROR: sha256 mismatch' if opts[:sha256].to_s != '' && Digest::SHA256.file(path).hexdigest != opts[:sha256].to_s
64
+
65
+ grep = opts[:grep].to_s
66
+ unless grep.empty?
67
+ rx = Regexp.new(grep, Regexp::IGNORECASE)
68
+ hits = []
69
+ File.foreach(path).with_index(1) do |ln, i|
70
+ hits << { line: i, text: ln.chomp } if ln.match?(rx)
71
+ break if hits.length >= 50
72
+ end
73
+ return { path: path, grep: grep, matches: hits }
74
+ end
75
+
58
76
  offset = opts[:offset].to_i
59
- length = (opts[:length] || 4_096).to_i
60
- length = 4_096 if length <= 0
77
+ length = (opts[:length] || 16_384).to_i
78
+ length = 16_384 if length <= 0
61
79
  mode = (opts[:mode] || 'text').to_s
62
80
  data = File.binread(path, length, offset).to_s
63
81
  case mode
@@ -86,9 +104,9 @@ module PWN
86
104
  FileUtils.mkdir_p(ROOT)
87
105
  File.open(meta, 'a') do |f|
88
106
  f.flock(File::LOCK_EX)
89
- f.puts(JSON.generate(sha256: sha, size: bytes.bytesize, tool: opts[:tool], session: opts[:session_id], kind: opts[:kind], created_at: Time.now.utc.iso8601, source_path: src, dest: dest))
107
+ f.puts(JSON.generate(sha256: sha, size: bytes.bytesize, tool: opts[:tool], session: opts[:session_id], kind: opts[:kind], tags: Array(opts[:tags]), created_at: Time.now.utc.iso8601, source_path: src, dest: dest))
90
108
  end
91
- { sha256: sha, path: dest, size: bytes.bytesize }
109
+ { sha256: sha, path: dest, size: bytes.bytesize, tags: Array(opts[:tags]) }
92
110
  end
93
111
 
94
112
  public_class_method def self.authors
@@ -114,15 +132,8 @@ module PWN
114
132
 
115
133
  # Read an artifact file and return sha256 plus a body cap.
116
134
  #{self}.get(
117
- path: 'required - filesystem path of a registered artifact'
118
- )
119
-
120
- # Page an artifact as text, hex, or base64 from an offset.
121
- #{self}.read_page(
122
- path: 'required - filesystem path of the artifact',
123
- offset: 'optional - byte offset (defaults to 0)',
124
- length: 'optional - byte count (defaults to 4096)',
125
- mode: 'optional - text, hex, or base64 (defaults to text)'
135
+ path: 'required - filesystem path of a registered artifact',
136
+ sha256: 'optional - content hash used when path is omitted'
126
137
  )
127
138
 
128
139
  # Store bytes under artifacts/sha256/<h2>/<hash> and append manifest.jsonl.
@@ -131,7 +142,19 @@ module PWN
131
142
  path: 'optional - filesystem path to read when bytes is omitted',
132
143
  tool: 'optional - tool name for provenance',
133
144
  session_id: 'optional - pwn-ai session id for provenance',
134
- kind: 'optional - artifact kind'
145
+ kind: 'optional - artifact kind',
146
+ tags: 'optional - Array of short labels for this artifact'
147
+ )
148
+
149
+ # Page an artifact; grep: searches lines, ref: alias for path.
150
+ #{self}.read_page(
151
+ path: 'required - filesystem path of the artifact',
152
+ ref: 'optional - alias for path',
153
+ offset: 'optional - byte offset (defaults to 0)',
154
+ length: 'optional - byte count (defaults to 16384)',
155
+ mode: 'optional - text, hex, or base64 (defaults to text)',
156
+ grep: 'optional - regex to search instead of paging',
157
+ sha256: 'optional - expected sha256 of the file'
135
158
  )
136
159
 
137
160
  # Print the AUTHOR(S) string for this module.
@@ -120,7 +120,10 @@ module PWN
120
120
  end
121
121
  body = summary.merge(path: path, sha256: sha)
122
122
  File.write(art, JSON.pretty_generate(body))
123
- body.merge(artifact: art, cached: false)
123
+ facts_dir = File.join(Dir.home, '.pwn', 'engagements', 'default', 'binaries', sha)
124
+ FileUtils.mkdir_p(facts_dir)
125
+ File.write(File.join(facts_dir, 'facts.json'), JSON.pretty_generate(body))
126
+ body.merge(artifact: art, cached: false, facts: File.join(facts_dir, 'facts.json'))
124
127
  end
125
128
 
126
129
  public_class_method def self.diff(opts = {})