pwn 0.5.602 → 0.5.606
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/.rubocop_todo.yml +1 -0
- data/Gemfile +2 -2
- data/README.md +3 -3
- data/lib/pwn/ai/agent/dispatch.rb +81 -0
- data/lib/pwn/ai/agent/loop.rb +201 -0
- data/lib/pwn/ai/agent/prompt_builder.rb +102 -0
- data/lib/pwn/ai/agent/registry.rb +149 -0
- data/lib/pwn/ai/agent/result.rb +105 -0
- data/lib/pwn/ai/agent/tools/memory.rb +75 -0
- data/lib/pwn/ai/agent/tools/ruby_eval.rb +45 -0
- data/lib/pwn/ai/agent/tools/shell.rb +44 -0
- data/lib/pwn/ai/agent/tools/skills.rb +76 -0
- data/lib/pwn/ai/agent.rb +13 -0
- data/lib/pwn/ai/anthropic.rb +198 -0
- data/lib/pwn/ai/gemini.rb +458 -0
- data/lib/pwn/ai/grok.rb +55 -3
- data/lib/pwn/ai/introspection.rb +9 -0
- data/lib/pwn/ai/ollama.rb +53 -0
- data/lib/pwn/ai/open_ai.rb +130 -103
- data/lib/pwn/ai.rb +7 -0
- data/lib/pwn/aws.rb +6 -0
- data/lib/pwn/blockchain/btc.rb +13 -8
- data/lib/pwn/blockchain.rb +6 -0
- data/lib/pwn/bounty/lifecycle_authz_replay.rb +26 -24
- data/lib/pwn/bounty.rb +6 -0
- data/lib/pwn/config.rb +34 -8
- data/lib/pwn/cron.rb +17 -12
- data/lib/pwn/ffi.rb +6 -0
- data/lib/pwn/memory.rb +11 -9
- data/lib/pwn/plugins/burp_suite.rb +3 -3
- data/lib/pwn/plugins/char.rb +4 -3
- data/lib/pwn/plugins/jenkins.rb +1 -1
- data/lib/pwn/plugins/oauth2.rb +2 -2
- data/lib/pwn/plugins/open_api.rb +82 -65
- data/lib/pwn/plugins/pony.rb +49 -45
- data/lib/pwn/plugins/repl.rb +232 -54
- data/lib/pwn/plugins/vault.rb +2 -2
- data/lib/pwn/plugins/vin.rb +6 -4
- data/lib/pwn/plugins/xxd.rb +4 -4
- data/lib/pwn/plugins.rb +6 -0
- data/lib/pwn/reports.rb +6 -0
- data/lib/pwn/sast.rb +6 -0
- data/lib/pwn/sdr/decoder/gsm.rb +2 -2
- data/lib/pwn/sdr/decoder.rb +6 -0
- data/lib/pwn/sdr/gqrx.rb +31 -31
- data/lib/pwn/sdr.rb +18 -2
- data/lib/pwn/sessions.rb +1 -1
- data/lib/pwn/version.rb +1 -1
- data/lib/pwn/www.rb +6 -0
- data/spec/conventions_spec.rb +149 -0
- data/spec/lib/pwn/ai/agent/dispatch_spec.rb +15 -0
- data/spec/lib/pwn/ai/agent/loop_spec.rb +15 -0
- data/spec/lib/pwn/ai/agent/prompt_builder_spec.rb +15 -0
- data/spec/lib/pwn/ai/agent/registry_spec.rb +15 -0
- data/spec/lib/pwn/ai/agent/result_spec.rb +15 -0
- data/spec/lib/pwn/ai/agent/tools/memory_spec.rb +10 -0
- data/spec/lib/pwn/ai/agent/tools/ruby_eval_spec.rb +10 -0
- data/spec/lib/pwn/ai/agent/tools/shell_spec.rb +10 -0
- data/spec/lib/pwn/ai/agent/tools/skills_spec.rb +10 -0
- data/spec/lib/pwn/ai/gemini_spec.rb +15 -0
- data/spec/lib/pwn/memory_spec.rb +1 -1
- metadata +26 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dda1231a5f5f7683e7d08172aaffff22a3b7acaa815db6a69e6e9b18cfbeaf13
|
|
4
|
+
data.tar.gz: aa697b79f429e439446356780e78c3b12c722b409c84954cd7ec01a145bc312c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 701991c1248d03dc2076035f23d7d36d07434b36a109eb548c228b350f46b8d1f5be357648f309057c92263a8902405b944063583924ab6895ee5683a9752bb0
|
|
7
|
+
data.tar.gz: e17d796a1f309d839d37cc452c239444bce25995bf0cc7289576b880e75e8e39016ba43a5ea958da0e9feab6b284b53824e99e5c207eb315dea4df0b727ff7f3
|
data/.rubocop_todo.yml
CHANGED
data/Gemfile
CHANGED
|
@@ -18,7 +18,7 @@ gem 'aws-sdk', '3.3.0'
|
|
|
18
18
|
gem 'barby', '0.7.0'
|
|
19
19
|
gem 'base32', '0.3.4'
|
|
20
20
|
gem 'bitcoin-ruby', '0.0.20'
|
|
21
|
-
gem 'brakeman', '8.0.
|
|
21
|
+
gem 'brakeman', '8.0.5'
|
|
22
22
|
gem 'bson', '5.2.0'
|
|
23
23
|
gem 'bundler', '>=4.0.14'
|
|
24
24
|
gem 'bundler-audit', '>=0.9.3'
|
|
@@ -49,7 +49,7 @@ gem 'jwt', '3.2.0'
|
|
|
49
49
|
gem 'libusb', '0.7.2'
|
|
50
50
|
gem 'luhn', '3.0.0'
|
|
51
51
|
gem 'mail', '2.9.0'
|
|
52
|
-
gem 'mcp', '0.
|
|
52
|
+
gem 'mcp', '0.20.0'
|
|
53
53
|
gem 'meshtastic', '0.0.163'
|
|
54
54
|
gem 'metasm', '1.0.6'
|
|
55
55
|
gem 'mongo', '2.24.1'
|
data/README.md
CHANGED
|
@@ -37,7 +37,7 @@ $ cd /opt/pwn
|
|
|
37
37
|
$ ./install.sh
|
|
38
38
|
$ ./install.sh ruby-gem
|
|
39
39
|
$ pwn
|
|
40
|
-
pwn[v0.5.
|
|
40
|
+
pwn[v0.5.606]:001 >>> PWN.help
|
|
41
41
|
```
|
|
42
42
|
|
|
43
43
|
[](https://youtu.be/G7iLUY4FzsI)
|
|
@@ -52,7 +52,7 @@ $ rvm use ruby-4.0.5@pwn
|
|
|
52
52
|
$ gem uninstall --all --executables pwn
|
|
53
53
|
$ gem install --verbose pwn
|
|
54
54
|
$ pwn
|
|
55
|
-
pwn[v0.5.
|
|
55
|
+
pwn[v0.5.606]:001 >>> PWN.help
|
|
56
56
|
```
|
|
57
57
|
|
|
58
58
|
If you're using a multi-user install of RVM do:
|
|
@@ -62,7 +62,7 @@ $ rvm use ruby-4.0.5@pwn
|
|
|
62
62
|
$ rvmsudo gem uninstall --all --executables pwn
|
|
63
63
|
$ rvmsudo gem install --verbose pwn
|
|
64
64
|
$ pwn
|
|
65
|
-
pwn[v0.5.
|
|
65
|
+
pwn[v0.5.606]:001 >>> PWN.help
|
|
66
66
|
```
|
|
67
67
|
|
|
68
68
|
PWN periodically upgrades to the latest version of Ruby which is reflected in `/opt/pwn/.ruby-version`. The easiest way to upgrade to the latest version of Ruby from a previous PWN installation is to run the following script:
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module PWN
|
|
6
|
+
module AI
|
|
7
|
+
module Agent
|
|
8
|
+
# Tool-call dispatch: takes a single tool_call object (OpenAI shape),
|
|
9
|
+
# looks up the registered handler, parses args, runs it, and returns a
|
|
10
|
+
# JSON string suitable for a role:'tool' message.
|
|
11
|
+
module Dispatch
|
|
12
|
+
# Supported Method Parameters::
|
|
13
|
+
# json_str = PWN::AI::Agent::Dispatch.call(
|
|
14
|
+
# tool_call: 'required - Hash { id:, type:, function: { name:, arguments: } }'
|
|
15
|
+
# )
|
|
16
|
+
|
|
17
|
+
public_class_method def self.call(opts = {})
|
|
18
|
+
tool_call = opts[:tool_call]
|
|
19
|
+
raise 'ERROR: tool_call is required' if tool_call.nil?
|
|
20
|
+
|
|
21
|
+
fn = tool_call[:function] || tool_call['function'] || {}
|
|
22
|
+
name = (fn[:name] || fn['name']).to_s
|
|
23
|
+
raw = fn[:arguments] || fn['arguments'] || '{}'
|
|
24
|
+
|
|
25
|
+
entry = Registry.lookup(name: name)
|
|
26
|
+
return JSON.generate(error: "unknown tool: #{name}") unless entry
|
|
27
|
+
|
|
28
|
+
args = parse_args(raw: raw)
|
|
29
|
+
result = entry.handler.call(args)
|
|
30
|
+
JSON.generate(success: true, result: result)
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
JSON.generate(
|
|
33
|
+
success: false,
|
|
34
|
+
error: "#{e.class}: #{e.message}",
|
|
35
|
+
backtrace: Array(e.backtrace).first(3)
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private_class_method def self.parse_args(opts = {})
|
|
40
|
+
raw = opts[:raw]
|
|
41
|
+
case raw
|
|
42
|
+
when Hash then symbolize(hash: raw)
|
|
43
|
+
when String then raw.strip.empty? ? {} : JSON.parse(raw, symbolize_names: true)
|
|
44
|
+
when nil then {}
|
|
45
|
+
else symbolize(hash: raw.to_h)
|
|
46
|
+
end
|
|
47
|
+
rescue JSON::ParserError => e
|
|
48
|
+
raise ArgumentError, "invalid JSON arguments: #{e.message}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private_class_method def self.symbolize(opts = {})
|
|
52
|
+
hash = opts[:hash] ||= {}
|
|
53
|
+
hash.each_with_object({}) { |(k, v), m| m[k.to_sym] = v }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Author(s):: 0day Inc. <support@0dayinc.com>
|
|
57
|
+
|
|
58
|
+
public_class_method def self.authors
|
|
59
|
+
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Display Usage for this Module
|
|
63
|
+
|
|
64
|
+
public_class_method def self.help
|
|
65
|
+
puts <<~USAGE
|
|
66
|
+
USAGE:
|
|
67
|
+
json_str = PWN::AI::Agent::Dispatch.call(
|
|
68
|
+
tool_call: {
|
|
69
|
+
id: 'call_1',
|
|
70
|
+
type: 'function',
|
|
71
|
+
function: { name: 'shell', arguments: '{"command":"id"}' }
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
#{self}.authors
|
|
76
|
+
USAGE
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module PWN
|
|
6
|
+
module AI
|
|
7
|
+
module Agent
|
|
8
|
+
# The agent conversation loop:
|
|
9
|
+
#
|
|
10
|
+
# build system prompt → call LLM with tools → if tool_calls: dispatch,
|
|
11
|
+
# append role:'tool' results, loop → else: return text.
|
|
12
|
+
#
|
|
13
|
+
# This replaces the regex-ReAct in PWN::Plugins::REPL :pwn_ai_hook with
|
|
14
|
+
# native function-calling. State (memory, skills, sessions) is all
|
|
15
|
+
# externalised — Loop.run is stateless aside from the messages array it
|
|
16
|
+
# builds.
|
|
17
|
+
module Loop
|
|
18
|
+
DEFAULT_MAX_ITERS = 25
|
|
19
|
+
|
|
20
|
+
ENGINE_MODS = {
|
|
21
|
+
openai: 'PWN::AI::OpenAI',
|
|
22
|
+
grok: 'PWN::AI::Grok',
|
|
23
|
+
ollama: 'PWN::AI::Ollama',
|
|
24
|
+
anthropic: 'PWN::AI::Anthropic',
|
|
25
|
+
gemini: 'PWN::AI::Gemini'
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
# Supported Method Parameters::
|
|
29
|
+
# final = PWN::AI::Agent::Loop.run(
|
|
30
|
+
# user_text: 'required - what the human typed',
|
|
31
|
+
# session_id: 'optional - PWN::Sessions id (transcript is appended to it)',
|
|
32
|
+
# enabled_toolsets: 'optional - subset of Registry.toolsets, or nil for all',
|
|
33
|
+
# on_tool: 'optional - ->(name, args, result) callback for live UI'
|
|
34
|
+
# )
|
|
35
|
+
|
|
36
|
+
public_class_method def self.run(opts = {})
|
|
37
|
+
user_text = opts[:user_text].to_s
|
|
38
|
+
session_id = opts[:session_id]
|
|
39
|
+
on_tool = opts[:on_tool]
|
|
40
|
+
|
|
41
|
+
Registry.discover
|
|
42
|
+
|
|
43
|
+
tools = Registry.definitions(enabled: opts[:enabled_toolsets])
|
|
44
|
+
messages = [
|
|
45
|
+
{ role: 'system', content: PromptBuilder.build(session_id: session_id) },
|
|
46
|
+
{ role: 'user', content: user_text }
|
|
47
|
+
]
|
|
48
|
+
append_session(session_id: session_id, role: 'user', content: user_text)
|
|
49
|
+
|
|
50
|
+
max_iters.times do |i|
|
|
51
|
+
msg = call_engine(messages: messages, tools: tools)
|
|
52
|
+
return '[pwn-ai] engine returned no message' if msg.nil?
|
|
53
|
+
|
|
54
|
+
messages << msg
|
|
55
|
+
calls = Array(msg[:tool_calls])
|
|
56
|
+
|
|
57
|
+
if calls.empty?
|
|
58
|
+
text = msg[:content].to_s
|
|
59
|
+
append_session(session_id: session_id, role: 'assistant', content: text)
|
|
60
|
+
return text
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
calls.each do |tc|
|
|
64
|
+
name = tc.dig(:function, :name).to_s
|
|
65
|
+
entry = Registry.lookup(name: name)
|
|
66
|
+
raw = Dispatch.call(tool_call: tc)
|
|
67
|
+
result = Result.condition(content: raw, entry: entry)
|
|
68
|
+
|
|
69
|
+
on_tool&.call(name, tc.dig(:function, :arguments), result)
|
|
70
|
+
|
|
71
|
+
messages << {
|
|
72
|
+
role: 'tool',
|
|
73
|
+
tool_call_id: tc[:id] || tc['id'] || "call_#{i}",
|
|
74
|
+
name: name,
|
|
75
|
+
content: result
|
|
76
|
+
}
|
|
77
|
+
append_session(session_id: session_id, role: 'tool', content: "#{name} → #{result[0, 400]}")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
'[pwn-ai] iteration budget exhausted'
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Supported Method Parameters::
|
|
85
|
+
# msg = PWN::AI::Agent::Loop.call_engine(
|
|
86
|
+
# messages: 'required - OpenAI-format messages array',
|
|
87
|
+
# tools: 'optional - OpenAI tools array'
|
|
88
|
+
# )
|
|
89
|
+
#
|
|
90
|
+
# Returns a normalised assistant message hash:
|
|
91
|
+
# { role: 'assistant', content: String|nil,
|
|
92
|
+
# tool_calls: [ {id:, type:'function', function:{name:, arguments:}} ],
|
|
93
|
+
# _native_content: <provider raw> (when adapter needs round-trip) }
|
|
94
|
+
|
|
95
|
+
public_class_method def self.call_engine(opts = {})
|
|
96
|
+
messages = opts[:messages]
|
|
97
|
+
tools = opts[:tools]
|
|
98
|
+
|
|
99
|
+
engine = (PWN::Env.dig(:ai, :active) if defined?(PWN::Env)).to_s.downcase.to_sym
|
|
100
|
+
engine = :openai if engine == :''
|
|
101
|
+
|
|
102
|
+
mod_name = ENGINE_MODS[engine]
|
|
103
|
+
raise "ERROR: Unsupported AI engine for agent loop: #{engine}" unless mod_name
|
|
104
|
+
|
|
105
|
+
mod = Object.const_get(mod_name)
|
|
106
|
+
if mod.respond_to?(:chat_raw)
|
|
107
|
+
normalise_openai(response: mod.chat_raw(messages: messages, tools: tools, spinner: true))
|
|
108
|
+
else
|
|
109
|
+
degrade_text_only(mod: mod, messages: messages)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Supported Method Parameters::
|
|
114
|
+
# msg = PWN::AI::Agent::Loop.normalise_openai(
|
|
115
|
+
# response: 'required - raw chat_raw response Hash from any provider'
|
|
116
|
+
# )
|
|
117
|
+
|
|
118
|
+
public_class_method def self.normalise_openai(opts = {})
|
|
119
|
+
resp = opts[:response]
|
|
120
|
+
return nil unless resp.is_a?(Hash)
|
|
121
|
+
|
|
122
|
+
msg = resp.dig(:choices, 0, :message) || resp[:assistant_message]
|
|
123
|
+
return nil unless msg
|
|
124
|
+
|
|
125
|
+
out = {
|
|
126
|
+
role: 'assistant',
|
|
127
|
+
content: msg[:content],
|
|
128
|
+
tool_calls: Array(msg[:tool_calls]).map do |tc|
|
|
129
|
+
{
|
|
130
|
+
id: tc[:id],
|
|
131
|
+
type: 'function',
|
|
132
|
+
function: {
|
|
133
|
+
name: tc.dig(:function, :name) || tc[:name],
|
|
134
|
+
arguments: tc.dig(:function, :arguments) || tc[:arguments]
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
end
|
|
138
|
+
}
|
|
139
|
+
# Preserve provider-native content blocks so chat_raw can round-trip
|
|
140
|
+
# them exactly on the next iteration (e.g. Anthropic requires the
|
|
141
|
+
# original tool_use block to precede a tool_result).
|
|
142
|
+
out[:_native_content] = msg[:_native_content] if msg[:_native_content]
|
|
143
|
+
out
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private_class_method def self.degrade_text_only(opts = {})
|
|
147
|
+
mod = opts[:mod]
|
|
148
|
+
messages = opts[:messages]
|
|
149
|
+
|
|
150
|
+
warn "[pwn-ai] #{mod} has no chat_raw — falling back to text-only (no tool-calling)"
|
|
151
|
+
sys = messages.find { |m| m[:role] == 'system' }
|
|
152
|
+
user = messages.rfind { |m| m[:role] == 'user' }
|
|
153
|
+
r = mod.chat(request: user[:content], system_role_content: sys&.[](:content), spinner: true)
|
|
154
|
+
txt = r.is_a?(Hash) ? (r.dig(:choices, -1, :content) || r.dig(:choices, -1, :text)).to_s : r.to_s
|
|
155
|
+
{ role: 'assistant', content: txt, tool_calls: [] }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private_class_method def self.max_iters
|
|
159
|
+
v = (PWN::Env.dig(:ai, :agent, :max_iters) if defined?(PWN::Env))
|
|
160
|
+
v.to_i.positive? ? v.to_i : DEFAULT_MAX_ITERS
|
|
161
|
+
rescue StandardError
|
|
162
|
+
DEFAULT_MAX_ITERS
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
private_class_method def self.append_session(opts = {})
|
|
166
|
+
session_id = opts[:session_id]
|
|
167
|
+
return unless session_id && defined?(PWN::Sessions)
|
|
168
|
+
|
|
169
|
+
PWN::Sessions.append(session_id: session_id, role: opts[:role], content: opts[:content])
|
|
170
|
+
rescue StandardError
|
|
171
|
+
nil
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Author(s):: 0day Inc. <support@0dayinc.com>
|
|
175
|
+
|
|
176
|
+
public_class_method def self.authors
|
|
177
|
+
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Display Usage for this Module
|
|
181
|
+
|
|
182
|
+
public_class_method def self.help
|
|
183
|
+
puts <<~USAGE
|
|
184
|
+
USAGE:
|
|
185
|
+
final = PWN::AI::Agent::Loop.run(
|
|
186
|
+
user_text: 'what does `id` return on this host?',
|
|
187
|
+
session_id: PWN::Sessions.create[:id],
|
|
188
|
+
enabled_toolsets: %w[terminal pwn memory skills],
|
|
189
|
+
on_tool: ->(name, args, result) { puts "→ \#{name}: \#{result[0,80]}" }
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
Supported engines: #{ENGINE_MODS.keys.join(', ')}
|
|
193
|
+
Set PWN::Env[:ai][:active] to choose; PWN::Env[:ai][:agent][:max_iters] to bound.
|
|
194
|
+
|
|
195
|
+
#{self}.authors
|
|
196
|
+
USAGE
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PWN
|
|
4
|
+
module AI
|
|
5
|
+
module Agent
|
|
6
|
+
# Assembles the system prompt for every Loop.run invocation from
|
|
7
|
+
# durable on-disk state: PWN::Env persona, host environment probe,
|
|
8
|
+
# PWN::Memory facts, and PWN::Skills index.
|
|
9
|
+
#
|
|
10
|
+
# Re-injection IS the persistence mechanism: this is rebuilt fresh on
|
|
11
|
+
# every user turn, so a memory_remember / skill_create from the prior
|
|
12
|
+
# turn shows up here with no extra wiring.
|
|
13
|
+
module PromptBuilder
|
|
14
|
+
# Supported Method Parameters::
|
|
15
|
+
# system_prompt = PWN::AI::Agent::PromptBuilder.build(
|
|
16
|
+
# session_id: 'optional - PWN::Sessions id to embed in the ENV block'
|
|
17
|
+
# )
|
|
18
|
+
|
|
19
|
+
public_class_method def self.build(opts = {})
|
|
20
|
+
session_id = opts[:session_id]
|
|
21
|
+
engine = active_engine
|
|
22
|
+
base = (PWN::Env.dig(:ai, engine, :system_role_content) if defined?(PWN::Env)) ||
|
|
23
|
+
'You are an offensive-security AI named Sonny operating inside the pwn REPL.'
|
|
24
|
+
|
|
25
|
+
<<~SYS.rstrip
|
|
26
|
+
#{base}
|
|
27
|
+
|
|
28
|
+
ENVIRONMENT
|
|
29
|
+
host : #{host_line}
|
|
30
|
+
cwd : #{Dir.pwd}
|
|
31
|
+
ruby : #{RUBY_VERSION}
|
|
32
|
+
pwn : #{pwn_version}
|
|
33
|
+
session_id : #{session_id || '(none)'}
|
|
34
|
+
|
|
35
|
+
#{memory_block}#{skills_block}TOOL USE
|
|
36
|
+
Use the provided function tools to act on the host. A reply with
|
|
37
|
+
no tool_calls is treated as your FINAL answer to the user.
|
|
38
|
+
Prefer `pwn_eval` for anything in the PWN:: namespace and `shell`
|
|
39
|
+
for OS commands. Save durable facts with `memory_remember`.
|
|
40
|
+
SYS
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private_class_method def self.active_engine
|
|
44
|
+
return :openai unless defined?(PWN::Env) && PWN::Env.is_a?(Hash)
|
|
45
|
+
|
|
46
|
+
PWN::Env.dig(:ai, :active).to_s.downcase.to_sym
|
|
47
|
+
rescue StandardError
|
|
48
|
+
:openai
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private_class_method def self.host_line
|
|
52
|
+
`uname -srm 2>/dev/null`.strip
|
|
53
|
+
rescue StandardError
|
|
54
|
+
RUBY_PLATFORM
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private_class_method def self.pwn_version
|
|
58
|
+
defined?(PWN::VERSION) ? PWN::VERSION : '?'
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private_class_method def self.memory_block
|
|
62
|
+
return '' unless defined?(PWN::Memory) && PWN::Memory.respond_to?(:to_context)
|
|
63
|
+
|
|
64
|
+
ctx = PWN::Memory.to_context(limit: 25).to_s
|
|
65
|
+
ctx.strip.empty? ? '' : "MEMORY#{ctx}\n\n"
|
|
66
|
+
rescue StandardError
|
|
67
|
+
''
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private_class_method def self.skills_block
|
|
71
|
+
return '' unless defined?(PWN::Skills) && PWN::Skills.is_a?(Hash) && !PWN::Skills.empty?
|
|
72
|
+
|
|
73
|
+
lines = PWN::Skills.map do |name, meta|
|
|
74
|
+
first = meta[:content].to_s.lines.first.to_s.strip
|
|
75
|
+
first = first[0, 100]
|
|
76
|
+
" - #{name}: #{first}"
|
|
77
|
+
end
|
|
78
|
+
"SKILLS (call skill_view to read full body)\n#{lines.join("\n")}\n\n"
|
|
79
|
+
rescue StandardError
|
|
80
|
+
''
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Author(s):: 0day Inc. <support@0dayinc.com>
|
|
84
|
+
|
|
85
|
+
public_class_method def self.authors
|
|
86
|
+
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Display Usage for this Module
|
|
90
|
+
|
|
91
|
+
public_class_method def self.help
|
|
92
|
+
puts <<~USAGE
|
|
93
|
+
USAGE:
|
|
94
|
+
system_prompt = PWN::AI::Agent::PromptBuilder.build(session_id: 'abc')
|
|
95
|
+
|
|
96
|
+
#{self}.authors
|
|
97
|
+
USAGE
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PWN
|
|
4
|
+
module AI
|
|
5
|
+
module Agent
|
|
6
|
+
# Central registry for pwn-ai agent tools.
|
|
7
|
+
#
|
|
8
|
+
# Each file under lib/pwn/ai/agent/tools/*.rb calls
|
|
9
|
+
# +PWN::AI::Agent::Registry.register(...)+ at load time to declare a
|
|
10
|
+
# JSON-Schema (what the LLM sees) and a handler lambda (what pwn runs).
|
|
11
|
+
#
|
|
12
|
+
# Registry.definitions(...) returns the OpenAI-format +tools:+ array;
|
|
13
|
+
# Registry.lookup(name:) returns the entry for dispatch.
|
|
14
|
+
#
|
|
15
|
+
# Import chain (circular-import safe):
|
|
16
|
+
# agent/registry.rb (no deps on tool files)
|
|
17
|
+
# ^
|
|
18
|
+
# agent/tools/*.rb (require registry, call .register at top level)
|
|
19
|
+
# ^
|
|
20
|
+
# agent/loop.rb (calls Registry.discover then .definitions)
|
|
21
|
+
module Registry
|
|
22
|
+
Entry = Struct.new(
|
|
23
|
+
:name, # String - tool name exposed to the model
|
|
24
|
+
:toolset, # String - grouping for enable/disable (terminal, file, pwn, memory…)
|
|
25
|
+
:schema, # Hash - OpenAI function schema {name:, description:, parameters:}
|
|
26
|
+
:handler, # Proc - ->(args_hash) { ... } returning a JSON-serialisable object
|
|
27
|
+
:check, # Proc - -> { bool } gate; tool only advertised when truthy
|
|
28
|
+
:max_chars, # Integer - cap on serialised result before it re-enters the convo
|
|
29
|
+
keyword_init: true
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
@entries = {}
|
|
33
|
+
@discovered = false
|
|
34
|
+
|
|
35
|
+
# Supported Method Parameters::
|
|
36
|
+
# PWN::AI::Agent::Registry.register(
|
|
37
|
+
# name: 'required - tool name exposed to the model',
|
|
38
|
+
# toolset: 'required - grouping for enable/disable (terminal, file, pwn, memory…)',
|
|
39
|
+
# schema: 'required - OpenAI function schema {name:, description:, parameters:}',
|
|
40
|
+
# handler: 'required - ->(args_hash) { ... } returning a JSON-serialisable object',
|
|
41
|
+
# check: 'optional - -> { bool } gate; tool only advertised when truthy',
|
|
42
|
+
# max_chars: 'optional - cap on serialised result (default 24_000)'
|
|
43
|
+
# )
|
|
44
|
+
|
|
45
|
+
public_class_method def self.register(opts = {})
|
|
46
|
+
name = opts[:name].to_s
|
|
47
|
+
raise 'ERROR: name is required' if name.empty?
|
|
48
|
+
raise 'ERROR: schema is required' unless opts[:schema]
|
|
49
|
+
raise 'ERROR: handler is required' unless opts[:handler].respond_to?(:call)
|
|
50
|
+
|
|
51
|
+
@entries[name] = Entry.new(
|
|
52
|
+
name: name,
|
|
53
|
+
toolset: opts[:toolset].to_s,
|
|
54
|
+
schema: opts[:schema],
|
|
55
|
+
handler: opts[:handler],
|
|
56
|
+
check: opts[:check] ||= -> { true },
|
|
57
|
+
max_chars: opts[:max_chars] ||= 24_000
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Supported Method Parameters::
|
|
62
|
+
# entry = PWN::AI::Agent::Registry.lookup(
|
|
63
|
+
# name: 'required - registered tool name'
|
|
64
|
+
# )
|
|
65
|
+
|
|
66
|
+
public_class_method def self.lookup(opts = {})
|
|
67
|
+
name = opts[:name]
|
|
68
|
+
@entries[name.to_s]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Supported Method Parameters::
|
|
72
|
+
# entries = PWN::AI::Agent::Registry.all
|
|
73
|
+
|
|
74
|
+
public_class_method def self.all
|
|
75
|
+
@entries.values
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Supported Method Parameters::
|
|
79
|
+
# names = PWN::AI::Agent::Registry.toolsets
|
|
80
|
+
|
|
81
|
+
public_class_method def self.toolsets
|
|
82
|
+
@entries.values.map(&:toolset).uniq.sort
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Supported Method Parameters::
|
|
86
|
+
# tools = PWN::AI::Agent::Registry.definitions(
|
|
87
|
+
# enabled: 'optional - Array of toolset names to include; nil = all whose check passes'
|
|
88
|
+
# )
|
|
89
|
+
|
|
90
|
+
public_class_method def self.definitions(opts = {})
|
|
91
|
+
enabled = opts[:enabled]
|
|
92
|
+
enabled = enabled.map(&:to_s) if enabled
|
|
93
|
+
@entries.values
|
|
94
|
+
.select { |e| (enabled.nil? || enabled.include?(e.toolset)) && safe_check(entry: e) }
|
|
95
|
+
.map { |e| { type: 'function', function: e.schema } }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Supported Method Parameters::
|
|
99
|
+
# names = PWN::AI::Agent::Registry.discover(
|
|
100
|
+
# force: 'optional - re-require tool files even if already discovered (default false)'
|
|
101
|
+
# )
|
|
102
|
+
|
|
103
|
+
public_class_method def self.discover(opts = {})
|
|
104
|
+
force = opts[:force] ||= false
|
|
105
|
+
return @entries.keys if @discovered && !force
|
|
106
|
+
|
|
107
|
+
tools_dir = File.join(__dir__, 'tools')
|
|
108
|
+
if Dir.exist?(tools_dir)
|
|
109
|
+
Dir[File.join(tools_dir, '*.rb')].each do |f|
|
|
110
|
+
require f
|
|
111
|
+
rescue StandardError, LoadError => e
|
|
112
|
+
warn "[pwn-ai] failed to load tool #{File.basename(f)}: #{e.class}: #{e.message}"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
@discovered = true
|
|
116
|
+
@entries.keys
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private_class_method def self.safe_check(opts = {})
|
|
120
|
+
entry = opts[:entry]
|
|
121
|
+
entry.check.call
|
|
122
|
+
rescue StandardError
|
|
123
|
+
false
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Author(s):: 0day Inc. <support@0dayinc.com>
|
|
127
|
+
|
|
128
|
+
public_class_method def self.authors
|
|
129
|
+
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Display Usage for this Module
|
|
133
|
+
|
|
134
|
+
public_class_method def self.help
|
|
135
|
+
puts <<~USAGE
|
|
136
|
+
USAGE:
|
|
137
|
+
PWN::AI::Agent::Registry.discover
|
|
138
|
+
PWN::AI::Agent::Registry.definitions(enabled: %w[terminal pwn])
|
|
139
|
+
PWN::AI::Agent::Registry.lookup(name: 'shell') # => Entry
|
|
140
|
+
PWN::AI::Agent::Registry.toolsets # => ["memory","pwn","skills","terminal"]
|
|
141
|
+
PWN::AI::Agent::Registry.register(name:, toolset:, schema:, handler:)
|
|
142
|
+
|
|
143
|
+
#{self}.authors
|
|
144
|
+
USAGE
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|