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.
- checksums.yaml +4 -4
- data/Gemfile +2 -2
- data/bin/pwn_setup +5 -5
- data/documentation/AI-Integration.md +34 -1
- data/documentation/Policy-Benchmark.md +151 -0
- data/documentation/Reinforcement-Learning.md +27 -1
- data/etc/default_skills/pwn/ai/agent/engagement/SKILL.md +1 -0
- data/etc/default_skills/pwn/ai/agent/learning/SKILL.md +2 -0
- data/etc/default_skills/pwn/ai/agent/metrics/SKILL.md +4 -0
- data/etc/default_skills/pwn/ai/agent/mistakes/SKILL.md +2 -0
- data/etc/default_skills/pwn/ai/agent/policy/SKILL.md +1 -0
- data/etc/default_skills/pwn/ai/agent/reward/SKILL.md +2 -0
- data/etc/default_skills/pwn/ai/agent/swarm/SKILL.md +6 -0
- data/etc/default_skills/pwn/ai/agent/tools/capabilities/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/context/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/context/SKILL.md +50 -0
- data/etc/default_skills/pwn/ai/http_retry/SKILL.md +7 -0
- data/etc/default_skills/pwn/ai/http_retry/references/urls.md +4 -0
- data/etc/default_skills/pwn/ai/open_ai/SKILL.md +1 -0
- data/etc/default_skills/pwn/ai/open_ai/references/urls.md +1 -0
- data/etc/default_skills/pwn/plugins/exploit_dev/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/findings/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/gdb/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/gdbmi/SKILL.md +55 -0
- data/etc/default_skills/pwn/plugins/ghidra_headless/SKILL.md +49 -0
- data/etc/default_skills/pwn/plugins/jobs/SKILL.md +6 -0
- data/etc/default_skills/pwn/plugins/packet/SKILL.md +3 -0
- data/etc/default_skills/pwn/plugins/preflight_checker/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/radare2/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/transparent_browser/SKILL.md +3 -0
- data/etc/default_skills/pwn/reports/engagement/SKILL.md +3 -2
- data/lib/pwn/ai/agent/curriculum.rb +37 -45
- data/lib/pwn/ai/agent/dispatch.rb +9 -0
- data/lib/pwn/ai/agent/engagement.rb +59 -0
- data/lib/pwn/ai/agent/learning.rb +111 -44
- data/lib/pwn/ai/agent/loop.rb +50 -8
- data/lib/pwn/ai/agent/metrics.rb +70 -2
- data/lib/pwn/ai/agent/mistakes.rb +67 -1
- data/lib/pwn/ai/agent/policy.rb +142 -16
- data/lib/pwn/ai/agent/registry.rb +1 -1
- data/lib/pwn/ai/agent/reward.rb +153 -53
- data/lib/pwn/ai/agent/swarm.rb +235 -35
- data/lib/pwn/ai/agent/tool_guard.rb +13 -1
- data/lib/pwn/ai/agent/tools/artifacts.rb +50 -3
- data/lib/pwn/ai/agent/tools/capabilities.rb +19 -0
- data/lib/pwn/ai/agent/tools/context.rb +38 -0
- data/lib/pwn/ai/agent/tools/finding_record.rb +18 -0
- data/lib/pwn/ai/agent/tools/fuzz_campaign.rb +10 -1
- data/lib/pwn/ai/agent/tools/job_run.rb +32 -0
- data/lib/pwn/ai/agent/tools/learning.rb +5 -6
- data/lib/pwn/ai/agent/tools/metrics.rb +16 -0
- data/lib/pwn/ai/agent/tools/pty_session.rb +4 -4
- data/lib/pwn/ai/agent/tools/ruby_eval.rb +6 -5
- data/lib/pwn/ai/agent/tools/shell.rb +10 -1
- data/lib/pwn/ai/agent/tools/skills.rb +30 -0
- data/lib/pwn/ai/agent/tools/swarm.rb +8 -2
- data/lib/pwn/ai/context.rb +193 -0
- data/lib/pwn/ai/http_retry.rb +53 -7
- data/lib/pwn/ai/open_ai.rb +302 -45
- data/lib/pwn/ai.rb +1 -0
- data/lib/pwn/migrate.rb +10 -1
- data/lib/pwn/plugins/artifact_registry.rb +40 -17
- data/lib/pwn/plugins/binary_parser.rb +4 -1
- data/lib/pwn/plugins/exploit_dev.rb +15 -0
- data/lib/pwn/plugins/findings.rb +48 -8
- data/lib/pwn/plugins/gdb.rb +17 -0
- data/lib/pwn/plugins/gdbmi.rb +128 -0
- data/lib/pwn/plugins/ghidra_headless.rb +104 -0
- data/lib/pwn/plugins/jobs.rb +72 -0
- data/lib/pwn/plugins/packet.rb +51 -0
- data/lib/pwn/plugins/preflight_checker.rb +29 -0
- data/lib/pwn/plugins/process_tube.rb +24 -7
- data/lib/pwn/plugins/radare2.rb +14 -2
- data/lib/pwn/plugins/repl.rb +6 -1
- data/lib/pwn/plugins/transparent_browser.rb +64 -0
- data/lib/pwn/plugins.rb +2 -0
- data/lib/pwn/reports/engagement.rb +19 -0
- data/lib/pwn/sessions.rb +3 -1
- data/lib/pwn/version.rb +1 -1
- data/scripts/benchmark_policy.rb +276 -0
- data/spec/documentation/installation_md_spec.rb +18 -4
- data/spec/integration/reinforced_feedback_loop_spec.rb +20 -13
- data/spec/lib/pwn/ai/agent/curriculum_spec.rb +267 -0
- data/spec/lib/pwn/ai/agent/engagement_spec.rb +12 -0
- data/spec/lib/pwn/ai/agent/learning_spec.rb +81 -3
- data/spec/lib/pwn/ai/agent/loop_spec.rb +51 -0
- data/spec/lib/pwn/ai/agent/metrics_spec.rb +44 -0
- data/spec/lib/pwn/ai/agent/mistakes_spec.rb +10 -0
- data/spec/lib/pwn/ai/agent/policy_spec.rb +103 -0
- data/spec/lib/pwn/ai/agent/registry_spec.rb +62 -0
- data/spec/lib/pwn/ai/agent/reward_spec.rb +175 -12
- data/spec/lib/pwn/ai/agent/swarm_spec.rb +121 -1
- data/spec/lib/pwn/ai/agent/tool_guard_spec.rb +6 -0
- data/spec/lib/pwn/ai/agent/tools/capabilities_spec.rb +14 -0
- data/spec/lib/pwn/ai/agent/tools/context_spec.rb +14 -0
- data/spec/lib/pwn/ai/agent/tools/job_run_spec.rb +2 -0
- data/spec/lib/pwn/ai/agent/tools/learning_spec.rb +25 -0
- data/spec/lib/pwn/ai/context_spec.rb +48 -0
- data/spec/lib/pwn/ai/http_retry_spec.rb +27 -0
- data/spec/lib/pwn/ai/open_ai_oauth_transport_spec.rb +245 -0
- data/spec/lib/pwn/ai/open_ai_spec.rb +150 -0
- data/spec/lib/pwn/migrate_spec.rb +24 -0
- data/spec/lib/pwn/plugins/artifact_registry_spec.rb +19 -0
- data/spec/lib/pwn/plugins/findings_spec.rb +2 -0
- data/spec/lib/pwn/plugins/gdbmi_spec.rb +17 -0
- data/spec/lib/pwn/plugins/ghidra_headless_spec.rb +17 -0
- data/third_party/pwn_rdoc.jsonl +86 -2
- metadata +24 -5
data/lib/pwn/ai/open_ai.rb
CHANGED
|
@@ -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.
|
|
38
|
-
#
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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(
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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}
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
puts
|
|
363
|
-
puts
|
|
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 =
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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 =
|
|
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
|
-
|
|
47
|
-
|
|
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] ||
|
|
60
|
-
length =
|
|
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
|
-
|
|
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 = {})
|