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
|
@@ -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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
data/lib/pwn/ai/http_retry.rb
CHANGED
|
@@ -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
|
-
|
|
41
|
+
headers = {}
|
|
42
42
|
resp = opts[:response]
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
#
|
|
125
|
+
# Seconds to sleep after a 429 (Retry-After, else exponential 2..60).
|
|
92
126
|
#{self}.retry_after_s(
|
|
93
|
-
retry_count: 'optional -
|
|
94
|
-
response: 'optional - response
|
|
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
|