pwn 0.5.603 → 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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +1 -0
  3. data/Gemfile +2 -2
  4. data/README.md +3 -3
  5. data/lib/pwn/ai/agent/dispatch.rb +81 -0
  6. data/lib/pwn/ai/agent/loop.rb +201 -0
  7. data/lib/pwn/ai/agent/prompt_builder.rb +102 -0
  8. data/lib/pwn/ai/agent/registry.rb +149 -0
  9. data/lib/pwn/ai/agent/result.rb +105 -0
  10. data/lib/pwn/ai/agent/tools/memory.rb +75 -0
  11. data/lib/pwn/ai/agent/tools/ruby_eval.rb +45 -0
  12. data/lib/pwn/ai/agent/tools/shell.rb +44 -0
  13. data/lib/pwn/ai/agent/tools/skills.rb +76 -0
  14. data/lib/pwn/ai/agent.rb +13 -0
  15. data/lib/pwn/ai/anthropic.rb +198 -0
  16. data/lib/pwn/ai/gemini.rb +458 -0
  17. data/lib/pwn/ai/grok.rb +55 -3
  18. data/lib/pwn/ai/introspection.rb +9 -0
  19. data/lib/pwn/ai/ollama.rb +53 -0
  20. data/lib/pwn/ai/open_ai.rb +130 -103
  21. data/lib/pwn/ai.rb +7 -0
  22. data/lib/pwn/aws.rb +6 -0
  23. data/lib/pwn/blockchain/btc.rb +13 -8
  24. data/lib/pwn/blockchain.rb +6 -0
  25. data/lib/pwn/bounty/lifecycle_authz_replay.rb +26 -24
  26. data/lib/pwn/bounty.rb +6 -0
  27. data/lib/pwn/config.rb +34 -8
  28. data/lib/pwn/cron.rb +17 -12
  29. data/lib/pwn/ffi.rb +6 -0
  30. data/lib/pwn/memory.rb +11 -9
  31. data/lib/pwn/plugins/burp_suite.rb +3 -3
  32. data/lib/pwn/plugins/char.rb +4 -3
  33. data/lib/pwn/plugins/jenkins.rb +1 -1
  34. data/lib/pwn/plugins/oauth2.rb +2 -2
  35. data/lib/pwn/plugins/open_api.rb +82 -65
  36. data/lib/pwn/plugins/pony.rb +49 -45
  37. data/lib/pwn/plugins/repl.rb +232 -54
  38. data/lib/pwn/plugins/vault.rb +2 -2
  39. data/lib/pwn/plugins/vin.rb +6 -4
  40. data/lib/pwn/plugins/xxd.rb +4 -4
  41. data/lib/pwn/plugins.rb +6 -0
  42. data/lib/pwn/reports.rb +6 -0
  43. data/lib/pwn/sast.rb +6 -0
  44. data/lib/pwn/sdr/decoder/gsm.rb +2 -2
  45. data/lib/pwn/sdr/decoder.rb +6 -0
  46. data/lib/pwn/sdr/gqrx.rb +31 -31
  47. data/lib/pwn/sdr.rb +18 -2
  48. data/lib/pwn/sessions.rb +1 -1
  49. data/lib/pwn/version.rb +1 -1
  50. data/lib/pwn/www.rb +6 -0
  51. data/spec/conventions_spec.rb +149 -0
  52. data/spec/lib/pwn/ai/agent/dispatch_spec.rb +15 -0
  53. data/spec/lib/pwn/ai/agent/loop_spec.rb +15 -0
  54. data/spec/lib/pwn/ai/agent/prompt_builder_spec.rb +15 -0
  55. data/spec/lib/pwn/ai/agent/registry_spec.rb +15 -0
  56. data/spec/lib/pwn/ai/agent/result_spec.rb +15 -0
  57. data/spec/lib/pwn/ai/agent/tools/memory_spec.rb +10 -0
  58. data/spec/lib/pwn/ai/agent/tools/ruby_eval_spec.rb +10 -0
  59. data/spec/lib/pwn/ai/agent/tools/shell_spec.rb +10 -0
  60. data/spec/lib/pwn/ai/agent/tools/skills_spec.rb +10 -0
  61. data/spec/lib/pwn/ai/gemini_spec.rb +15 -0
  62. data/spec/lib/pwn/memory_spec.rb +1 -1
  63. metadata +26 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2d9cef98cd11c0d946179ae3bee8e726c491ae0328455c8aaef8380fa392c3a8
4
- data.tar.gz: 6abdf135e81a2596166ddfae6ec93bb738e8eeeb60ed27a4517f7c061898dff8
3
+ metadata.gz: dda1231a5f5f7683e7d08172aaffff22a3b7acaa815db6a69e6e9b18cfbeaf13
4
+ data.tar.gz: aa697b79f429e439446356780e78c3b12c722b409c84954cd7ec01a145bc312c
5
5
  SHA512:
6
- metadata.gz: 3be83f533d5311947b2bde851319956aa6cee5c8047e5d8aa5b03768c1db0052fa25211b34e096b0bd7858f8327fd1c02998000fb546624f79718c745f2a2d7f
7
- data.tar.gz: e164db59ece4dc7440047008346a10680642a3f647231f23a0330ef87fb3b3556b5147700bcb7f12483c9f8f3c81f2dc291bb23a01eb35db7a604eac5dca7036
6
+ metadata.gz: 701991c1248d03dc2076035f23d7d36d07434b36a109eb548c228b350f46b8d1f5be357648f309057c92263a8902405b944063583924ab6895ee5683a9752bb0
7
+ data.tar.gz: e17d796a1f309d839d37cc452c239444bce25995bf0cc7289576b880e75e8e39016ba43a5ea958da0e9feab6b284b53824e99e5c207eb315dea4df0b727ff7f3
data/.rubocop_todo.yml CHANGED
@@ -70,6 +70,7 @@ Metrics/MethodLength:
70
70
  Naming/AccessorMethodName:
71
71
  Exclude:
72
72
  - 'lib/pwn/ai/anthropic.rb'
73
+ - 'lib/pwn/ai/gemini.rb'
73
74
  - 'lib/pwn/ai/grok.rb'
74
75
  - 'lib/pwn/ai/ollama.rb'
75
76
  - 'lib/pwn/ai/open_ai.rb'
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.4'
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.18.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.603]:001 >>> PWN.help
40
+ pwn[v0.5.606]:001 >>> PWN.help
41
41
  ```
42
42
 
43
43
  [![Installing the pwn Security Automation Framework](https://raw.githubusercontent.com/0dayInc/pwn/master/documentation/pwn_install.png)](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.603]:001 >>> PWN.help
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.603]:001 >>> PWN.help
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