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
@@ -59,3 +59,19 @@ PWN::AI::Agent::Registry.register(
59
59
  { reset: true, tools_cleared: before, file: PWN::AI::Agent::Metrics::METRICS_FILE }
60
60
  }
61
61
  )
62
+
63
+ PWN::AI::Agent::Registry.register(
64
+ name: 'ai_usage',
65
+ toolset: 'metrics',
66
+ schema: {
67
+ name: 'ai_usage',
68
+ description: 'Per-call token counts and cumulative cost plus configured model routing.',
69
+ parameters: { type: 'object', properties: { session_id: { type: 'string' } } }
70
+ },
71
+ handler: lambda { |args|
72
+ {
73
+ usage: PWN::AI::Agent::Metrics.usage(session_id: args[:session_id] || args['session_id']),
74
+ routing: PWN::AI::Agent::Metrics.routing
75
+ }
76
+ }
77
+ )
@@ -10,12 +10,12 @@ PWN::AI::Agent::Registry.register(
10
10
  description: 'Open a persistent PTY (gdb, r2, ssh, sh). Returns {id, pid}.',
11
11
  parameters: {
12
12
  type: 'object',
13
- properties: { cmd: { type: 'string' }, command: { type: 'string' } },
13
+ properties: { cmd: { type: 'string' }, command: { type: 'string' }, name: { type: 'string' } },
14
14
  required: %w[cmd]
15
15
  }
16
16
  },
17
17
  handler: lambda { |args|
18
- PWN::Plugins::ProcessTube.spawn(cmd: args[:cmd] || args[:command] || args['cmd'])
18
+ PWN::Plugins::ProcessTube.spawn(cmd: args[:cmd] || args[:command] || args['cmd'], name: args[:name] || args['name'])
19
19
  }
20
20
  )
21
21
  PWN::AI::Agent::Registry.register(
@@ -26,12 +26,12 @@ PWN::AI::Agent::Registry.register(
26
26
  description: 'Write a line to a PTY opened by pty_open.',
27
27
  parameters: {
28
28
  type: 'object',
29
- properties: { id: { type: 'string' }, line: { type: 'string' }, data: { type: 'string' } },
29
+ properties: { id: { type: 'string' }, name: { type: 'string' }, line: { type: 'string' }, data: { type: 'string' } },
30
30
  required: %w[id]
31
31
  }
32
32
  },
33
33
  handler: lambda { |args|
34
- PWN::Plugins::ProcessTube.write_line(id: args[:id] || args['id'], line: args[:line] || args[:data] || args['line'])
34
+ PWN::Plugins::ProcessTube.write_line(id: args[:id] || args[:name] || args['id'] || args['name'], line: args[:line] || args[:data] || args['line'])
35
35
  }
36
36
  )
37
37
  PWN::AI::Agent::Registry.register(
@@ -54,11 +54,12 @@ PWN::AI::Agent::Registry.register(
54
54
 
55
55
  code = args[:code].to_s
56
56
  if code.strip.empty? || PWN::AI::Agent::ToolGuard.placeholder?(text: code)
57
- return {
58
- stdout: '',
59
- error: 'invalid_payload',
60
- hint: 'code is required (string). Do not send ..., {...}, {…}, or empty. Example: pwn_eval(code="1 + 1").'
61
- }
57
+ return PWN::AI::Agent::ToolGuard.invalid_payload(
58
+ hint: 'code is required (string). Do not send ..., {...}, {…}, or empty. Example: pwn_eval(code="1 + 1"). Triple-dot inside quoted/heredoc bodies is allowed.',
59
+ offending_token: '...',
60
+ text: code,
61
+ suggestion: 'replace ellipsis or placeholders with concrete Ruby'
62
+ )
62
63
  end
63
64
 
64
65
  old_stdout = $stdout
@@ -56,11 +56,20 @@ PWN::AI::Agent::Registry.register(
56
56
  .gsub(/\\+\s*\z/, '')
57
57
  .strip
58
58
  timeout = PWN::AI::Agent::ToolGuard.deadline_s(timeout: args[:timeout], kind: :shell, payload: cmd)
59
+ if cmd.bytesize > PWN::AI::Agent::ToolGuard::MAX_PAYLOAD_BYTES
60
+ return PWN::AI::Agent::ToolGuard.invalid_payload(
61
+ hint: "command exceeds max payload size #{PWN::AI::Agent::ToolGuard::MAX_PAYLOAD_BYTES} bytes",
62
+ text: cmd,
63
+ byte_range: [PWN::AI::Agent::ToolGuard::MAX_PAYLOAD_BYTES, cmd.bytesize],
64
+ code: 'PAYLOAD_TOO_LARGE'
65
+ )
66
+ end
59
67
  if cmd.empty? || PWN::AI::Agent::ToolGuard.placeholder?(text: cmd)
60
68
  return PWN::AI::Agent::ToolGuard.invalid_payload(
61
69
  hint: 'command is required (string). Do not send ..., {...}, {…}, or empty. ' \
62
- 'Example: shell(command="uname -r").',
70
+ 'Example: shell(command="uname -r"). Triple-dot inside quoted/heredoc bodies is allowed.',
63
71
  offending_token: '...',
72
+ text: cmd,
64
73
  code: 'SYNTAX_DENY',
65
74
  suggestion: 'replace ellipsis or placeholders with a concrete command'
66
75
  )
@@ -241,6 +241,36 @@ PWN::AI::Agent::Registry.register(
241
241
  }
242
242
  )
243
243
 
244
+ PWN::AI::Agent::Registry.register(
245
+ name: 'skills_create',
246
+ toolset: 'skills',
247
+ schema: {
248
+ name: 'skills_create',
249
+ description: 'Create a new skill under ~/.pwn/skills. Requires confirm:true (operator gate).',
250
+ parameters: {
251
+ type: 'object',
252
+ properties: {
253
+ name: { type: 'string' },
254
+ sop_markdown: { type: 'string' },
255
+ content: { type: 'string' },
256
+ description: { type: 'string' },
257
+ confirm: { type: 'boolean' }
258
+ },
259
+ required: %w[name]
260
+ }
261
+ },
262
+ handler: lambda { |args|
263
+ raise ArgumentError, 'refusing to create a skill without confirm:true' unless args[:confirm] == true
264
+
265
+ PWN::Config.write_skill(
266
+ name: args[:name],
267
+ description: args[:description],
268
+ content: args[:sop_markdown] || args[:content] || args['sop_markdown'] || args['content'],
269
+ pwn_skills_path: PWN::Config.pwn_skills_path
270
+ ).merge(saved: true)
271
+ }
272
+ )
273
+
244
274
  PWN::AI::Agent::Registry.register(
245
275
  name: 'skill_add_reference',
246
276
  toolset: 'skills',
@@ -102,7 +102,10 @@ PWN::AI::Agent::Registry.register(
102
102
  type: 'string',
103
103
  description: 'Existing swarm to join (from swarm_list / a prior ' \
104
104
  'agent_ask). Omit to auto-create a new swarm.'
105
- }
105
+ },
106
+ unit: { type: 'string', description: 'Claim key host:phase so two children do not duplicate work.' },
107
+ ttl: { type: 'integer', description: 'Claim TTL seconds (defaults to 300).' },
108
+ engagement_id: { type: 'string', description: 'Claim namespace (defaults to swarm_id).' }
106
109
  },
107
110
  required: %w[name request]
108
111
  }
@@ -113,7 +116,10 @@ PWN::AI::Agent::Registry.register(
113
116
  PWN::AI::Agent::Swarm.ask(
114
117
  name: args[:name],
115
118
  request: args[:request],
116
- swarm_id: args[:swarm_id]
119
+ swarm_id: args[:swarm_id],
120
+ unit: args[:unit],
121
+ ttl: args[:ttl],
122
+ engagement_id: args[:engagement_id]
117
123
  )
118
124
  }
119
125
  )
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'fileutils'
5
+ require 'json'
6
+
7
+ module PWN
8
+ module AI
9
+ # Attach files, hexdumps, disassembly, and HTTP transcripts to model
10
+ # context with auto-chunking. Oversize artifacts are summarized inline
11
+ # and persisted under ~/.pwn/artifacts/<session_id>/ with SHA-256.
12
+ module Context
13
+ WINDOW = 32_768
14
+ CHUNK = 8_192
15
+
16
+ public_class_method def self.attach_file(opts = {})
17
+ path = opts[:path].to_s
18
+ raise 'ERROR: path is required' if path.empty?
19
+ raise "ERROR: file not found: #{path}" unless File.file?(path)
20
+
21
+ range = opts[:range]
22
+ data = if range.is_a?(Range)
23
+ File.binread(path, range.end - range.begin, range.begin).to_s
24
+ else
25
+ File.binread(path)
26
+ end
27
+ persist_and_chunk(bytes: data, path: path, kind: 'file', session_id: opts[:session_id], mime: mime_of(path: path))
28
+ end
29
+
30
+ public_class_method def self.attach_hexdump(opts = {})
31
+ path = opts[:path].to_s
32
+ raise 'ERROR: path is required' if path.empty?
33
+ raise "ERROR: file not found: #{path}" unless File.file?(path)
34
+
35
+ offset = opts[:offset].to_i
36
+ length = (opts[:length] || 512).to_i
37
+ length = 512 if length <= 0
38
+ slice = File.binread(path, length, offset).to_s
39
+ hex = slice.unpack1('H*')
40
+ persist_and_chunk(bytes: hex, path: path, kind: 'hexdump', session_id: opts[:session_id], extra: { offset: offset, length: slice.bytesize })
41
+ end
42
+
43
+ public_class_method def self.attach_disasm(opts = {})
44
+ path = opts[:path].to_s
45
+ raise 'ERROR: path is required' if path.empty?
46
+
47
+ fn = opts[:function].to_s
48
+ text = if defined?(PWN::Plugins::Radare2) && File.file?(path)
49
+ sid = PWN::Plugins::Radare2.open(path: path)
50
+ addr = fn.empty? ? 'main' : fn
51
+ PWN::Plugins::Radare2.disasm(session: sid, addr: addr, n: 64)
52
+ else
53
+ File.binread(path).to_s[0, CHUNK]
54
+ end
55
+ persist_and_chunk(bytes: text.to_s, path: path, kind: 'disasm', session_id: opts[:session_id], extra: { function: fn })
56
+ end
57
+
58
+ public_class_method def self.attach_http_transcript(opts = {})
59
+ src = opts[:har_or_raw] || opts[:har] || opts[:raw] || opts[:path]
60
+ bytes = if src.to_s.empty?
61
+ raise 'ERROR: har_or_raw is required'
62
+ elsif File.file?(src.to_s)
63
+ File.binread(src.to_s)
64
+ else
65
+ src.to_s
66
+ end
67
+ persist_and_chunk(bytes: bytes, path: src.to_s, kind: 'http_transcript', session_id: opts[:session_id])
68
+ end
69
+
70
+ public_class_method def self.authors
71
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
72
+ end
73
+
74
+ public_class_method def self.help
75
+ puts "USAGE:
76
+ # Attach a file (optionally a byte range) and chunk it for the model.
77
+ #{self}.attach_file(
78
+ path: 'required - filesystem path to attach',
79
+ range: 'optional - Range of bytes to attach instead of the whole file',
80
+ session_id: 'optional - pwn-ai session id for the artifact store'
81
+ )
82
+
83
+ # Attach a hexdump slice of a binary.
84
+ #{self}.attach_hexdump(
85
+ path: 'required - filesystem path of the binary',
86
+ offset: 'optional - byte offset (defaults to 0)',
87
+ length: 'optional - byte count (defaults to 512)',
88
+ session_id: 'optional - pwn-ai session id for the artifact store'
89
+ )
90
+
91
+ # Attach disassembly of a function (r2 when present).
92
+ #{self}.attach_disasm(
93
+ path: 'required - filesystem path of the binary',
94
+ function: 'optional - function or address (defaults to main)',
95
+ session_id: 'optional - pwn-ai session id for the artifact store'
96
+ )
97
+
98
+ # Attach a HAR or raw HTTP transcript.
99
+ #{self}.attach_http_transcript(
100
+ har_or_raw: 'required - HAR/raw string or filesystem path',
101
+ har: 'optional - alias for har_or_raw',
102
+ raw: 'optional - alias for har_or_raw',
103
+ path: 'optional - filesystem path of a HAR file',
104
+ session_id: 'optional - pwn-ai session id for the artifact store'
105
+ )
106
+
107
+ # Print the AUTHOR(S) string for this module.
108
+ #{self}.authors
109
+ "
110
+ constants.sort
111
+ end
112
+
113
+ private_class_method def self.persist_and_chunk(opts = {})
114
+ bytes = opts[:bytes].to_s
115
+ mime = opts[:mime].to_s
116
+ summary = if binary_mime?(mime: mime, bytes: bytes)
117
+ binary_summary(bytes: bytes, path: opts[:path])
118
+ else
119
+ bytes[0, WINDOW]
120
+ end
121
+ stored = if defined?(PWN::Plugins::ArtifactRegistry)
122
+ PWN::Plugins::ArtifactRegistry.put(bytes: bytes, path: opts[:path], kind: opts[:kind], session_id: opts[:session_id])
123
+ else
124
+ sid = (opts[:session_id] || 'default').to_s
125
+ sha = Digest::SHA256.hexdigest(bytes)
126
+ dir = File.join(Dir.home, '.pwn', 'artifacts', sid)
127
+ FileUtils.mkdir_p(dir)
128
+ dest = File.join(dir, sha)
129
+ File.binwrite(dest, bytes)
130
+ { sha256: sha, path: dest, size: bytes.bytesize }
131
+ end
132
+ chunks = []
133
+ body = summary.to_s
134
+ i = 0
135
+ while i < body.bytesize
136
+ chunks << body.byteslice(i, CHUNK)
137
+ i += CHUNK
138
+ end
139
+ {
140
+ sha256: stored[:sha256],
141
+ path: stored[:path],
142
+ size: bytes.bytesize,
143
+ kind: opts[:kind],
144
+ summarized: bytes.bytesize > WINDOW,
145
+ chunks: chunks,
146
+ extra: opts[:extra]
147
+ }.merge(opts[:extra].is_a?(Hash) ? opts[:extra] : {})
148
+ end
149
+
150
+ private_class_method def self.mime_of(opts = {})
151
+ path = opts[:path].to_s
152
+ return 'application/octet-stream' unless File.file?(path)
153
+
154
+ head = File.binread(path, 16).to_s
155
+ return 'application/x-elf' if head.start_with?("\x7fELF")
156
+ return 'application/x-mach-binary' if head.start_with?("\xCF\xFA\xED\xFE") || head.start_with?("\xCE\xFA\xED\xFE")
157
+ return 'application/x-dosexec' if head.start_with?('MZ')
158
+ return 'application/json' if path.end_with?('.json', '.har')
159
+ return 'text/plain' if head.force_encoding('UTF-8').valid_encoding?
160
+
161
+ 'application/octet-stream'
162
+ end
163
+
164
+ private_class_method def self.binary_mime?(opts = {})
165
+ mime = opts[:mime].to_s
166
+ return true if mime.start_with?('application/x-') || mime == 'application/octet-stream'
167
+
168
+ bytes = opts[:bytes].to_s
169
+ bytes.encoding == Encoding::ASCII_8BIT && bytes.bytes.any? { |b| b < 9 || (b > 13 && b < 32) }
170
+ end
171
+
172
+ private_class_method def self.binary_summary(opts = {})
173
+ bytes = opts[:bytes].to_s
174
+ printable = bytes.gsub(/[^\x20-\x7e]/n, '').byteslice(0, 2_048)
175
+ entropy = shannon(bytes: bytes.byteslice(0, 4_096))
176
+ "strings=#{printable[0, 400]} entropy=#{entropy.round(2)} size=#{bytes.bytesize} path=#{opts[:path]}"
177
+ end
178
+
179
+ private_class_method def self.shannon(opts = {})
180
+ data = opts[:bytes].to_s
181
+ return 0.0 if data.empty?
182
+
183
+ freq = Hash.new(0)
184
+ data.each_byte { |b| freq[b] += 1 }
185
+ n = data.bytesize.to_f
186
+ freq.values.sum do |c|
187
+ p = c / n
188
+ p.positive? ? -p * Math.log2(p) : 0
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
@@ -38,11 +38,45 @@ module PWN
38
38
 
39
39
  public_class_method def self.retry_after_s(opts = {})
40
40
  retry_count = [opts[:retry_count].to_i, 1].max
41
- retry_after = 0
41
+ headers = {}
42
42
  resp = opts[:response]
43
- retry_after = resp.headers[:retry_after].to_i if resp.respond_to?(:headers) && resp.headers
44
- retry_after = (0.5 * retry_count) if retry_after.to_f <= 0
45
- retry_after
43
+ headers = resp.headers if resp.respond_to?(:headers) && resp.headers
44
+ ra = headers[:retry_after] || headers['retry-after'] || headers['Retry-After']
45
+ if ra.to_s.match?(/\A\d+(\.\d+)?\z/)
46
+ n = ra.to_f
47
+ return n if n.positive?
48
+ end
49
+
50
+ [2**retry_count, 60].min.to_f
51
+ end
52
+
53
+ public_class_method def self.quota_exhausted?(opts = {})
54
+ err = opts[:error]
55
+ blob = err.respond_to?(:message) ? err.message.to_s : err.to_s
56
+ blob = opts[:message].to_s if blob.empty?
57
+ if err.respond_to?(:response) && err.response
58
+ blob = "#{blob} #{err.response}"
59
+ blob = "#{blob} #{err.response.body}" if err.response.respond_to?(:body)
60
+ end
61
+ blob.match?(/insufficient_quota|credit_balance_exhausted|exceeded your current quota|billing_not_active|spend.?limit|no credits remaining/i)
62
+ rescue StandardError
63
+ false
64
+ end
65
+
66
+ public_class_method def self.quota_message(opts = {})
67
+ err = opts[:error]
68
+ blob = ''
69
+ blob = err.message.to_s if err.respond_to?(:message)
70
+ if err.respond_to?(:response) && err.response
71
+ blob = "#{blob} #{err.response}"
72
+ blob = "#{blob} #{err.response.body}" if err.response.respond_to?(:body)
73
+ end
74
+ blob = opts[:message].to_s if blob.strip.empty?
75
+ url = blob[%r{https://platform\.openai\.com/settings/organization/billing/?}] ||
76
+ 'https://platform.openai.com/settings/organization/billing/'
77
+ 'OpenAI API has no credits (insufficient_quota / credit_balance_exhausted). ' \
78
+ "Add prepaid API credits at #{url} — a ChatGPT Plus/Pro plan does not fund api.openai.com. " \
79
+ 'Then retry; /model openai stays selected.'
46
80
  end
47
81
 
48
82
  # Tees provider REST events into the open pwn-ai DEBUG RN log and STDERR.
@@ -88,10 +122,22 @@ module PWN
88
122
  message: 'required - message value consumed by #retryable?'
89
123
  )
90
124
 
91
- # Run retry after s and return its result
125
+ # Seconds to sleep after a 429 (Retry-After, else exponential 2..60).
92
126
  #{self}.retry_after_s(
93
- retry_count: 'optional - retry count value consumed by #retry_after_s',
94
- response: 'optional - response value consumed by #retry_after_s'
127
+ retry_count: 'optional - 1-based attempt used for exponential fallback',
128
+ response: 'optional - RestClient response with Retry-After header'
129
+ )
130
+
131
+ # True when a 429 body is billing/quota, not a retryable rate limit.
132
+ #{self}.quota_exhausted?(
133
+ error: 'optional - exception whose message/response body is inspected',
134
+ message: 'optional - raw error text when error is omitted'
135
+ )
136
+
137
+ # Operator-facing line for a billing/quota 429 (do not retry).
138
+ #{self}.quota_message(
139
+ error: 'optional - exception whose response body may contain the billing URL',
140
+ message: 'optional - raw error text when error is omitted'
95
141
  )
96
142
 
97
143
  # Tees provider REST events into the open pwn-ai DEBUG RN log and STDERR