pwn 0.5.613 → 0.5.617

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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +10 -10
  3. data/README.md +117 -17
  4. data/bin/pwn +47 -2
  5. data/bin/pwn_gqrx_scanner +75 -18
  6. data/documentation/AI-Integration.md +36 -0
  7. data/documentation/BurpSuite.md +26 -0
  8. data/documentation/Contributing.md +31 -0
  9. data/documentation/Diagrams.md +140 -0
  10. data/documentation/Drivers.md +25 -0
  11. data/documentation/General-PWN-Usage.md +60 -0
  12. data/documentation/Home.md +78 -0
  13. data/documentation/How-PWN-Works.md +62 -0
  14. data/documentation/Installation.md +75 -0
  15. data/documentation/NmapIt.md +18 -0
  16. data/documentation/Plugins.md +59 -0
  17. data/documentation/Reporting.md +28 -0
  18. data/documentation/SAST.md +32 -0
  19. data/documentation/Skills-Memory-Learning.md +44 -0
  20. data/documentation/Transparent-Browser.md +32 -0
  21. data/documentation/Troubleshooting.md +38 -0
  22. data/documentation/What-is-PWN.md +53 -0
  23. data/documentation/Why-PWN.md +48 -0
  24. data/documentation/diagrams/ai-integration-tool-calling.svg +172 -0
  25. data/documentation/diagrams/build.sh +20 -0
  26. data/documentation/diagrams/burp-vs-zap-preference.svg +69 -0
  27. data/documentation/diagrams/code-scanning-sast.svg +172 -0
  28. data/documentation/diagrams/dot/_THEME.md +27 -0
  29. data/documentation/diagrams/dot/agent-tool-registry.dot +59 -0
  30. data/documentation/diagrams/dot/ai-integration-tool-calling.dot +50 -0
  31. data/documentation/diagrams/dot/aws-cloud-security.dot +48 -0
  32. data/documentation/diagrams/dot/burp-vs-zap-preference.dot +26 -0
  33. data/documentation/diagrams/dot/code-scanning-sast.dot +49 -0
  34. data/documentation/diagrams/dot/cron-scheduling.dot +40 -0
  35. data/documentation/diagrams/dot/driver-framework.dot +39 -0
  36. data/documentation/diagrams/dot/extrospection-world-awareness.dot +55 -0
  37. data/documentation/diagrams/dot/fuzzing-workflow.dot +47 -0
  38. data/documentation/diagrams/dot/history-to-drivers.dot +34 -0
  39. data/documentation/diagrams/dot/memory-skills-detailed.dot +60 -0
  40. data/documentation/diagrams/dot/network-infra-testing.dot +54 -0
  41. data/documentation/diagrams/dot/overall-pwn-architecture.dot +115 -0
  42. data/documentation/diagrams/dot/penetration-testing-workflow.dot +60 -0
  43. data/documentation/diagrams/dot/plugin-ecosystem.dot +45 -0
  44. data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +98 -0
  45. data/documentation/diagrams/dot/pwn-repl-prototyping.dot +56 -0
  46. data/documentation/diagrams/dot/reporting-pipeline.dot +46 -0
  47. data/documentation/diagrams/dot/reverse-engineering-flow.dot +43 -0
  48. data/documentation/diagrams/dot/sdr-radio-flow.dot +45 -0
  49. data/documentation/diagrams/dot/sessions-cron-automation.dot +44 -0
  50. data/documentation/diagrams/dot/swarm-multi-agent.dot +70 -0
  51. data/documentation/diagrams/dot/web-application-testing.dot +49 -0
  52. data/documentation/diagrams/dot/zero-day-research-flow.dot +49 -0
  53. data/documentation/diagrams/driver-framework.svg +111 -0
  54. data/documentation/diagrams/fuzzing-workflow.svg +160 -0
  55. data/documentation/diagrams/history-to-drivers.svg +119 -0
  56. data/documentation/diagrams/memory-skills-detailed.svg +202 -0
  57. data/documentation/diagrams/network-infra-testing.svg +183 -0
  58. data/documentation/diagrams/overall-pwn-architecture.svg +405 -0
  59. data/documentation/diagrams/penetration-testing-workflow.svg +201 -0
  60. data/documentation/diagrams/plugin-ecosystem.svg +276 -0
  61. data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +306 -0
  62. data/documentation/diagrams/pwn-repl-prototyping.svg +168 -0
  63. data/documentation/diagrams/reporting-pipeline.svg +151 -0
  64. data/documentation/diagrams/reverse-engineering-flow.svg +134 -0
  65. data/documentation/diagrams/sessions-cron-automation.svg +136 -0
  66. data/documentation/diagrams/web-application-testing.svg +173 -0
  67. data/documentation/diagrams/zero-day-research-flow.svg +153 -0
  68. data/documentation/pwn-REPL.md +33 -0
  69. data/documentation/pwn-ai-Agent.md +43 -0
  70. data/git_commit.sh +1 -1
  71. data/lib/pwn/ai/agent/assembly.rb +1 -1
  72. data/lib/pwn/ai/agent/btc.rb +1 -1
  73. data/lib/pwn/ai/agent/burp_suite.rb +1 -1
  74. data/lib/pwn/ai/agent/extrospection.rb +618 -0
  75. data/lib/pwn/ai/agent/gqrx.rb +2 -2
  76. data/lib/pwn/ai/agent/hacker_one.rb +1 -1
  77. data/lib/pwn/ai/agent/introspection.rb +91 -0
  78. data/lib/pwn/ai/agent/learning.rb +379 -0
  79. data/lib/pwn/ai/agent/loop.rb +33 -3
  80. data/lib/pwn/ai/agent/metrics.rb +155 -0
  81. data/lib/pwn/ai/agent/prompt_builder.rb +32 -3
  82. data/lib/pwn/ai/agent/sast.rb +1 -1
  83. data/lib/pwn/ai/agent/swarm.rb +437 -0
  84. data/lib/pwn/ai/agent/tools/cron.rb +163 -0
  85. data/lib/pwn/ai/agent/tools/extrospection.rb +280 -0
  86. data/lib/pwn/ai/agent/tools/learning.rb +221 -0
  87. data/lib/pwn/ai/agent/tools/memory.rb +27 -0
  88. data/lib/pwn/ai/agent/tools/metrics.rb +61 -0
  89. data/lib/pwn/ai/agent/tools/sessions.rb +139 -0
  90. data/lib/pwn/ai/agent/tools/skills.rb +124 -8
  91. data/lib/pwn/ai/agent/tools/swarm.rb +229 -0
  92. data/lib/pwn/ai/agent/transparent_browser.rb +1 -1
  93. data/lib/pwn/ai/agent/vuln_gen.rb +2 -2
  94. data/lib/pwn/ai/agent.rb +5 -0
  95. data/lib/pwn/ai/anthropic.rb +19 -4
  96. data/lib/pwn/ai/grok.rb +249 -106
  97. data/lib/pwn/ai.rb +0 -1
  98. data/lib/pwn/config.rb +96 -36
  99. data/lib/pwn/cron.rb +16 -7
  100. data/lib/pwn/plugins/monkey_patch.rb +2 -2
  101. data/lib/pwn/plugins/repl.rb +158 -284
  102. data/lib/pwn/sdr/decoder/base.rb +251 -0
  103. data/lib/pwn/sdr/decoder/gsm.rb +84 -185
  104. data/lib/pwn/sdr/decoder/pocsag.rb +62 -217
  105. data/lib/pwn/sdr/decoder.rb +1 -0
  106. data/lib/pwn/sdr/gqrx.rb +446 -65
  107. data/lib/pwn/version.rb +1 -1
  108. data/spec/lib/pwn/ai/agent/extrospection_spec.rb +15 -0
  109. data/spec/lib/pwn/ai/agent/introspection_spec.rb +15 -0
  110. data/spec/lib/pwn/ai/agent/learning_spec.rb +68 -0
  111. data/spec/lib/pwn/ai/agent/metrics_spec.rb +27 -0
  112. data/spec/lib/pwn/ai/agent/swarm_spec.rb +34 -0
  113. data/spec/lib/pwn/ai/agent/tools/cron_spec.rb +40 -0
  114. data/spec/lib/pwn/ai/agent/tools/extrospection_spec.rb +40 -0
  115. data/spec/lib/pwn/ai/agent/tools/learning_spec.rb +30 -0
  116. data/spec/lib/pwn/ai/agent/tools/metrics_spec.rb +20 -0
  117. data/spec/lib/pwn/ai/agent/tools/sessions_spec.rb +35 -0
  118. data/spec/lib/pwn/ai/agent/tools/swarm_spec.rb +17 -0
  119. data/spec/lib/pwn/{ai/introspection_spec.rb → sdr/decoder/base_spec.rb} +3 -3
  120. data/third_party/pwn_rdoc.jsonl +178 -33
  121. metadata +110 -36
  122. data/documentation/PWN.png +0 -0
  123. data/documentation/PWN_Contributors_and_Users.png +0 -0
  124. data/documentation/fax-spectrogram.png +0 -0
  125. data/documentation/fax-waveform.png +0 -0
  126. data/documentation/frozen_string_literal_files_changed_from_false_to_true.txt +0 -54
  127. data/documentation/lifecycle_authz_replay.example.yaml +0 -27
  128. data/documentation/pwn_android_war_dialer_session.png +0 -0
  129. data/documentation/pwn_install.png +0 -0
  130. data/documentation/pwn_wallpaper.jpg +0 -0
  131. data/documentation/ringing-spectrogram.png +0 -0
  132. data/documentation/ringing-waveform.png +0 -0
  133. data/documentation/vulnerability_report_template.md +0 -37
  134. data/lib/pwn/ai/introspection.rb +0 -76
@@ -31,7 +31,7 @@ module PWN
31
31
  pwn : #{pwn_version}
32
32
  session_id : #{session_id || '(none)'}
33
33
 
34
- #{memory_block}#{skills_block}TOOL USE
34
+ #{memory_block}#{skills_block}#{learning_block}#{metrics_block}#{extrospection_block}TOOL USE
35
35
  Use the provided function tools to act on the host. A reply with
36
36
  no tool_calls is treated as your FINAL answer to the user.
37
37
  Prefer `pwn_eval` for anything in the PWN:: namespace and `shell`
@@ -70,15 +70,44 @@ module PWN
70
70
  return '' unless defined?(PWN::Skills) && PWN::Skills.is_a?(Hash) && !PWN::Skills.empty?
71
71
 
72
72
  lines = PWN::Skills.map do |name, meta|
73
- first = meta[:content].to_s.lines.first.to_s.strip
73
+ first = meta[:content].to_s.lines.reject { |l| l.strip.empty? || l.start_with?('---') }.first.to_s.strip
74
74
  first = first[0, 100]
75
- " - #{name}: #{first}"
75
+ rc = Array(meta[:references]).length
76
+ ref_tag = rc.positive? ? " [#{rc} refs]" : ''
77
+ " - #{name}: #{first}#{ref_tag}"
76
78
  end
77
79
  "SKILLS (call skill_view to read full body)\n#{lines.join("\n")}\n\n"
78
80
  rescue StandardError
79
81
  ''
80
82
  end
81
83
 
84
+ private_class_method def self.learning_block
85
+ return '' unless defined?(PWN::AI::Agent::Learning)
86
+
87
+ ctx = PWN::AI::Agent::Learning.to_context(limit: 5).to_s
88
+ ctx.strip.empty? ? '' : "LEARNING\n#{ctx}"
89
+ rescue StandardError
90
+ ''
91
+ end
92
+
93
+ private_class_method def self.metrics_block
94
+ return '' unless defined?(PWN::AI::Agent::Metrics)
95
+
96
+ ctx = PWN::AI::Agent::Metrics.to_context(limit: 8).to_s
97
+ ctx.strip.empty? ? '' : ctx
98
+ rescue StandardError
99
+ ''
100
+ end
101
+
102
+ private_class_method def self.extrospection_block
103
+ return '' unless defined?(PWN::AI::Agent::Extrospection)
104
+
105
+ ctx = PWN::AI::Agent::Extrospection.to_context.to_s
106
+ ctx.strip.empty? ? '' : ctx
107
+ rescue StandardError
108
+ ''
109
+ end
110
+
82
111
  # Author(s):: 0day Inc. <support@0dayinc.com>
83
112
 
84
113
  public_class_method def self.authors
@@ -16,7 +16,7 @@ module PWN
16
16
 
17
17
  system_role_content = 'Your sole purpose is to analyze source code snippets and generate an Exploit Prediction Scoring System (EPSS) score between 0% - 100%. Just generate a score unless score is >= 75% in which a PoC and code fix should also be included.'
18
18
 
19
- PWN::AI::Introspection.reflect_on(
19
+ PWN::AI::Agent::Introspection.reflect_on(
20
20
  system_role_content: system_role_content,
21
21
  request: request,
22
22
  suppress_pii_warning: true
@@ -0,0 +1,437 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'yaml'
5
+ require 'time'
6
+ require 'fileutils'
7
+ require 'securerandom'
8
+
9
+ module PWN
10
+ module AI
11
+ module Agent
12
+ # Native multi-agent orchestration for pwn-ai.
13
+ #
14
+ # Swarm replaces the legacy `pwn-irc` mechanism (inspircd + weechat +
15
+ # PRIVMSG-flattened .chat calls) with first-class sub-agents built on
16
+ # top of PWN::AI::Agent::Loop.run. Each persona is a full tool-calling
17
+ # agent — Memory, Skills, Learning, Metrics and Extrospection all
18
+ # apply — so the self-improvement loop covers the whole swarm.
19
+ #
20
+ # ~/.pwn/agents.yml # persona registry
21
+ # ~/.pwn/swarm/<swarm_id>/bus.jsonl # append-only message bus
22
+ # ~/.pwn/swarm/<swarm_id>/personas.json# persona -> PWN::Sessions id
23
+ #
24
+ # Cross-session / cross-process communication == another pwn-ai (or a
25
+ # PWN::Cron job) calling Swarm.ask/debate with the same swarm_id and
26
+ # reading the same bus.jsonl. No daemon required.
27
+ module Swarm
28
+ AGENTS_FILE = File.join(Dir.home, '.pwn', 'agents.yml')
29
+ SWARM_ROOT = File.join(Dir.home, '.pwn', 'swarm')
30
+ DEFAULT_DEPTH = 3
31
+ DEFAULT_ITERS = 25
32
+ DEFAULT_TAIL = 12
33
+ DEFAULT_TOOLSET = %w[terminal pwn memory skills sessions learning
34
+ metrics extrospection].freeze
35
+
36
+ # ------------------------------------------------------------------
37
+ # Persona registry (~/.pwn/agents.yml)
38
+ # ------------------------------------------------------------------
39
+
40
+ # Supported Method Parameters::
41
+ # personas = PWN::AI::Agent::Swarm.personas
42
+
43
+ public_class_method def self.personas
44
+ return {} unless File.exist?(AGENTS_FILE)
45
+
46
+ raw = YAML.safe_load_file(
47
+ AGENTS_FILE,
48
+ permitted_classes: [Symbol],
49
+ aliases: true,
50
+ symbolize_names: true
51
+ ) || {}
52
+ raw.transform_values { |v| normalize_persona(persona: v) }
53
+ rescue StandardError => e
54
+ warn "[pwn-ai/swarm] failed to load #{AGENTS_FILE}: #{e.class}: #{e.message}"
55
+ {}
56
+ end
57
+
58
+ # Supported Method Parameters::
59
+ # PWN::AI::Agent::Swarm.spawn(
60
+ # name: 'required - persona name (snake_case)',
61
+ # role: 'required - system_role_content overlay for this persona',
62
+ # toolsets: 'optional - Array of Registry toolset names',
63
+ # engine: 'optional - :openai / :anthropic / :grok / :gemini / :ollama',
64
+ # max_iters: 'optional - per-turn iteration cap for this persona'
65
+ # )
66
+
67
+ public_class_method def self.spawn(opts = {})
68
+ name = opts[:name].to_s
69
+ raise ArgumentError, 'name is required' if name.strip.empty?
70
+ raise ArgumentError, 'role is required' if opts[:role].to_s.strip.empty?
71
+
72
+ all = personas
73
+ all[name.to_sym] = normalize_persona(persona: opts)
74
+ FileUtils.mkdir_p(File.dirname(AGENTS_FILE))
75
+ File.write(AGENTS_FILE, YAML.dump(deep_stringify(hash: all)))
76
+ { name: name, persona: all[name.to_sym], file: AGENTS_FILE }
77
+ end
78
+
79
+ # Supported Method Parameters::
80
+ # PWN::AI::Agent::Swarm.retire(name: 'required - persona name')
81
+
82
+ public_class_method def self.retire(opts = {})
83
+ name = opts[:name].to_s
84
+ all = personas
85
+ gone = all.delete(name.to_sym)
86
+ File.write(AGENTS_FILE, YAML.dump(deep_stringify(hash: all))) if gone
87
+ { name: name, removed: !gone.nil? }
88
+ end
89
+
90
+ # ------------------------------------------------------------------
91
+ # Swarm lifecycle & bus
92
+ # ------------------------------------------------------------------
93
+
94
+ # Supported Method Parameters::
95
+ # swarm = PWN::AI::Agent::Swarm.create(topic: 'optional')
96
+
97
+ public_class_method def self.create(opts = {})
98
+ id = "#{Time.now.utc.strftime('%Y%m%d_%H%M%S')}_#{SecureRandom.hex(3)}"
99
+ dir = File.join(SWARM_ROOT, id)
100
+ FileUtils.mkdir_p(dir)
101
+ bus_append(swarm_id: id, from: :system, to: :all,
102
+ content: "swarm #{id} created: #{opts[:topic] || '(no topic)'}")
103
+ { swarm_id: id, dir: dir, bus: bus_path(swarm_id: id) }
104
+ end
105
+
106
+ # Supported Method Parameters::
107
+ # swarms = PWN::AI::Agent::Swarm.list
108
+
109
+ public_class_method def self.list
110
+ FileUtils.mkdir_p(SWARM_ROOT)
111
+ Dir.children(SWARM_ROOT).sort.reverse.map do |id|
112
+ bp = bus_path(swarm_id: id)
113
+ {
114
+ swarm_id: id,
115
+ dir: File.join(SWARM_ROOT, id),
116
+ messages: File.exist?(bp) ? File.foreach(bp).count : 0,
117
+ mtime: File.exist?(bp) ? File.mtime(bp).utc.iso8601 : nil
118
+ }
119
+ end
120
+ end
121
+
122
+ # Supported Method Parameters::
123
+ # PWN::AI::Agent::Swarm.bus_append(
124
+ # swarm_id: 'required', from: 'required', content: 'required',
125
+ # to: 'optional (default :all)'
126
+ # )
127
+
128
+ public_class_method def self.bus_append(opts = {})
129
+ sid = opts[:swarm_id].to_s
130
+ raise ArgumentError, 'swarm_id is required' if sid.empty?
131
+
132
+ FileUtils.mkdir_p(File.join(SWARM_ROOT, sid))
133
+ entry = {
134
+ ts: Time.now.utc.iso8601,
135
+ from: opts[:from].to_s,
136
+ to: (opts[:to] || :all).to_s,
137
+ content: opts[:content].to_s
138
+ }
139
+ File.open(bus_path(swarm_id: sid), 'a') { |f| f.puts(JSON.generate(entry)) }
140
+ entry
141
+ end
142
+
143
+ # Supported Method Parameters::
144
+ # msgs = PWN::AI::Agent::Swarm.bus_tail(swarm_id: 'required', limit: 12)
145
+
146
+ public_class_method def self.bus_tail(opts = {})
147
+ sid = opts[:swarm_id].to_s
148
+ limit = (opts[:limit] || DEFAULT_TAIL).to_i
149
+ bp = bus_path(swarm_id: sid)
150
+ return [] unless File.exist?(bp)
151
+
152
+ File.readlines(bp).last(limit).map { |l| JSON.parse(l, symbolize_names: true) }
153
+ rescue StandardError
154
+ []
155
+ end
156
+
157
+ # ------------------------------------------------------------------
158
+ # Core: run a single persona turn under Loop.run
159
+ # ------------------------------------------------------------------
160
+
161
+ # Supported Method Parameters::
162
+ # reply = PWN::AI::Agent::Swarm.ask(
163
+ # name: 'required - persona name from ~/.pwn/agents.yml',
164
+ # request: 'required - what to ask/instruct the persona',
165
+ # swarm_id: 'optional - join an existing swarm (created if omitted)',
166
+ # to: 'optional - addressee recorded on the bus (default :all)',
167
+ # on_tool: 'optional - ->(name, args, result) live-UI callback'
168
+ # )
169
+
170
+ public_class_method def self.ask(opts = {})
171
+ name = opts[:name].to_s
172
+ persona = personas[name.to_sym]
173
+ raise ArgumentError, "unknown persona: #{name} (see #{AGENTS_FILE})" unless persona
174
+
175
+ sid = opts[:swarm_id] || create(topic: opts[:request].to_s[0, 60])[:swarm_id]
176
+ depth = Thread.current[:pwn_swarm_depth] || 0
177
+ if depth >= max_depth
178
+ raise "swarm recursion depth #{depth} >= max_depth #{max_depth} " \
179
+ '(PWN::Env[:ai][:agent][:max_depth])'
180
+ end
181
+
182
+ bus_append(swarm_id: sid, from: opts[:from] || caller_label,
183
+ to: name, content: opts[:request].to_s)
184
+
185
+ session_id = persona_session(swarm_id: sid, name: name)
186
+ sys = build_persona_prompt(name: name, persona: persona,
187
+ swarm_id: sid, session_id: session_id)
188
+
189
+ Thread.current[:pwn_swarm_depth] = depth + 1
190
+ reply = with_persona_env(persona: persona) do
191
+ Loop.run(
192
+ request: opts[:request].to_s,
193
+ session_id: session_id,
194
+ enabled_toolsets: persona[:toolsets],
195
+ system_role_content: sys,
196
+ on_tool: opts[:on_tool]
197
+ )
198
+ end
199
+
200
+ bus_append(swarm_id: sid, from: name, to: opts[:to] || :all, content: reply)
201
+ { swarm_id: sid, name: name, session_id: session_id, reply: reply }
202
+ ensure
203
+ Thread.current[:pwn_swarm_depth] = depth
204
+ end
205
+
206
+ # Supported Method Parameters::
207
+ # result = PWN::AI::Agent::Swarm.debate(
208
+ # names: 'required - Array of persona names, order = speaking order',
209
+ # topic: 'required - opening question / claim',
210
+ # rounds: 'optional - full passes over names (default 2)',
211
+ # swarm_id: 'optional - join an existing swarm',
212
+ # on_tool: 'optional - ->(name, args, result) live-UI callback'
213
+ # )
214
+
215
+ public_class_method def self.debate(opts = {})
216
+ names = Array(opts[:names]).map(&:to_s)
217
+ raise ArgumentError, 'names must contain at least 2 personas' if names.length < 2
218
+
219
+ topic = opts[:topic].to_s
220
+ raise ArgumentError, 'topic is required' if topic.strip.empty?
221
+
222
+ rounds = (opts[:rounds] || 2).to_i
223
+ sid = opts[:swarm_id] || create(topic: topic)[:swarm_id]
224
+
225
+ last_speaker = 'moderator'
226
+ last_msg = topic
227
+ transcript = []
228
+
229
+ rounds.times do |r|
230
+ names.each do |n|
231
+ req = if r.zero? && n == names.first
232
+ topic
233
+ else
234
+ "@#{last_speaker} said:\n#{last_msg}\n\n" \
235
+ 'Respond, critique, or advance the objective.'
236
+ end
237
+ res = ask(name: n, request: req, swarm_id: sid,
238
+ from: last_speaker, to: n, on_tool: opts[:on_tool])
239
+ transcript << { round: r + 1, name: n, reply: res[:reply] }
240
+ last_speaker = n
241
+ last_msg = res[:reply]
242
+ end
243
+ end
244
+
245
+ { swarm_id: sid, rounds: rounds, names: names,
246
+ transcript: transcript, bus: bus_path(swarm_id: sid) }
247
+ end
248
+
249
+ # Supported Method Parameters::
250
+ # result = PWN::AI::Agent::Swarm.broadcast(
251
+ # request: 'required', names: 'optional - default all personas',
252
+ # swarm_id: 'optional'
253
+ # )
254
+
255
+ public_class_method def self.broadcast(opts = {})
256
+ req = opts[:request].to_s
257
+ raise ArgumentError, 'request is required' if req.strip.empty?
258
+
259
+ names = Array(opts[:names]).map(&:to_s)
260
+ names = personas.keys.map(&:to_s) if names.empty?
261
+ sid = opts[:swarm_id] || create(topic: req[0, 60])[:swarm_id]
262
+
263
+ replies = names.to_h do |n|
264
+ [n, ask(name: n, request: req, swarm_id: sid,
265
+ from: 'broadcast', on_tool: opts[:on_tool])[:reply]]
266
+ end
267
+ { swarm_id: sid, replies: replies }
268
+ end
269
+
270
+ # ------------------------------------------------------------------
271
+ # privates
272
+ # ------------------------------------------------------------------
273
+
274
+ private_class_method def self.bus_path(opts = {})
275
+ File.join(SWARM_ROOT, opts[:swarm_id].to_s, 'bus.jsonl')
276
+ end
277
+
278
+ private_class_method def self.persona_session(opts = {})
279
+ sid = opts[:swarm_id].to_s
280
+ name = opts[:name].to_s
281
+ map_path = File.join(SWARM_ROOT, sid, 'personas.json')
282
+ map = File.exist?(map_path) ? JSON.parse(File.read(map_path)) : {}
283
+ return map[name] if map[name]
284
+
285
+ sess = PWN::Sessions.create(
286
+ title: "swarm:#{sid} persona:#{name}",
287
+ source: 'pwn-ai-swarm'
288
+ )
289
+ map[name] = sess[:id]
290
+ File.write(map_path, JSON.pretty_generate(map))
291
+ sess[:id]
292
+ end
293
+
294
+ private_class_method def self.build_persona_prompt(opts = {})
295
+ name = opts[:name]
296
+ persona = opts[:persona]
297
+ sid = opts[:swarm_id]
298
+ base = PromptBuilder.build(session_id: opts[:session_id])
299
+
300
+ bus = bus_tail(swarm_id: sid, limit: DEFAULT_TAIL).map do |m|
301
+ " [#{m[:ts]}] #{m[:from]} → #{m[:to]}: #{m[:content].to_s.tr("\n", ' ')[0, 400]}"
302
+ end.join("\n")
303
+
304
+ peers = (personas.keys.map(&:to_s) - [name]).join(', ')
305
+
306
+ <<~PROMPT
307
+ #{base}
308
+
309
+ SWARM
310
+ swarm_id : #{sid}
311
+ you_are : #{name}
312
+ peers : #{peers.empty? ? '(none)' : peers}
313
+ depth : #{Thread.current[:pwn_swarm_depth] || 0} / #{max_depth}
314
+ (Use agent_ask to delegate to a peer only if strictly necessary
315
+ and you have the 'swarm' toolset — depth is capped.)
316
+
317
+ PERSONA (#{name})
318
+ #{persona[:role]}
319
+
320
+ SWARM BUS (last #{DEFAULT_TAIL} msgs, newest last)
321
+ #{bus.empty? ? ' (empty)' : bus}
322
+ PROMPT
323
+ end
324
+
325
+ # Temporarily override PWN::Env[:ai][:active] and
326
+ # PWN::Env[:ai][:agent][:max_iters] for the duration of a sub-agent
327
+ # turn, restoring both afterwards even on raise.
328
+ private_class_method def self.with_persona_env(opts = {})
329
+ persona = opts[:persona]
330
+ ai = env_ai
331
+ return yield unless ai
332
+
333
+ agent_h = (ai[:agent] ||= {})
334
+ prev_active = ai[:active]
335
+ prev_iters = agent_h[:max_iters]
336
+
337
+ ai[:active] = persona[:engine].to_s if persona[:engine]
338
+ agent_h[:max_iters] = persona[:max_iters] if persona[:max_iters]
339
+ yield
340
+ ensure
341
+ if ai
342
+ ai[:active] = prev_active
343
+ agent_h[:max_iters] = prev_iters
344
+ end
345
+ end
346
+
347
+ private_class_method def self.normalize_persona(opts = {})
348
+ p = opts[:persona] || {}
349
+ {
350
+ role: p[:role].to_s,
351
+ engine: (p[:engine].to_s.empty? ? nil : p[:engine].to_s.downcase.to_sym),
352
+ toolsets: Array(p[:toolsets]).map(&:to_s).then { |a| a.empty? ? DEFAULT_TOOLSET.dup : a },
353
+ max_iters: (p[:max_iters] || DEFAULT_ITERS).to_i
354
+ }
355
+ end
356
+
357
+ private_class_method def self.max_depth
358
+ v = (PWN::Env.dig(:ai, :agent, :max_depth) if defined?(PWN::Env))
359
+ v.to_i.positive? ? v.to_i : DEFAULT_DEPTH
360
+ rescue StandardError
361
+ DEFAULT_DEPTH
362
+ end
363
+
364
+ private_class_method def self.env_ai
365
+ return nil unless defined?(PWN::Env) && PWN::Env.is_a?(Hash)
366
+
367
+ ai = PWN::Env[:ai]
368
+ ai.is_a?(Hash) && !ai.frozen? ? ai : nil
369
+ rescue StandardError
370
+ nil
371
+ end
372
+
373
+ private_class_method def self.caller_label
374
+ d = Thread.current[:pwn_swarm_depth] || 0
375
+ d.zero? ? 'orchestrator' : "depth#{d}"
376
+ end
377
+
378
+ private_class_method def self.deep_stringify(opts = {})
379
+ h = opts[:hash]
380
+ case h
381
+ when Hash then h.to_h { |k, v| [k.to_s, deep_stringify(hash: v)] }
382
+ when Array then h.map { |v| deep_stringify(hash: v) }
383
+ when Symbol then h.to_s
384
+ else h
385
+ end
386
+ end
387
+
388
+ # Author(s):: 0day Inc. <support@0dayinc.com>
389
+
390
+ public_class_method def self.authors
391
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
392
+ end
393
+
394
+ # Display Usage for this Module
395
+
396
+ public_class_method def self.help
397
+ puts <<~USAGE
398
+ USAGE:
399
+ # Define personas (or edit #{AGENTS_FILE} directly)
400
+ PWN::AI::Agent::Swarm.spawn(
401
+ name: 'red',
402
+ role: 'Offensive researcher. Propose the most likely exploit path...',
403
+ toolsets: %w[terminal pwn memory extrospection],
404
+ engine: :anthropic
405
+ )
406
+
407
+ # One-shot: ask a persona (creates a swarm if none given)
408
+ r = PWN::AI::Agent::Swarm.ask(name: 'red', request: 'Enumerate attack surface for target X')
409
+
410
+ # Antagonistic feedback loop
411
+ d = PWN::AI::Agent::Swarm.debate(
412
+ names: %w[red blue],
413
+ topic: 'Is CVE-2026-NNNN exploitable on target X?',
414
+ rounds: 3
415
+ )
416
+ puts d[:transcript].map { |t| "\#{t[:name]}: \#{t[:reply][0,200]}" }
417
+
418
+ # Fan-out
419
+ PWN::AI::Agent::Swarm.broadcast(request: 'Summarise findings so far')
420
+
421
+ # Inspect / resume cross-session
422
+ PWN::AI::Agent::Swarm.list
423
+ PWN::AI::Agent::Swarm.bus_tail(swarm_id: d[:swarm_id], limit: 50)
424
+
425
+ Config:
426
+ PWN::Env[:ai][:agent][:max_depth] # recursion cap (default #{DEFAULT_DEPTH})
427
+ persona[:max_iters] # per-turn iteration cap (default #{DEFAULT_ITERS})
428
+ persona[:toolsets] # Registry toolset allow-list; omit 'swarm'
429
+ # to prevent that persona spawning sub-agents.
430
+
431
+ #{self}.authors
432
+ USAGE
433
+ end
434
+ end
435
+ end
436
+ end
437
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pwn/ai/agent/registry'
4
+ require 'pwn/cron'
5
+
6
+ # Thin wrappers around PWN::Cron so the model can schedule, inspect, run,
7
+ # enable/disable and remove recurring pwn-ai jobs (~/.pwn/cron/jobs.yml).
8
+ # Jobs may carry a pwn-ai `prompt`, a `ruby` snippet, or an external
9
+ # `script` path; system-cron installation is opt-in via :install_crontab.
10
+
11
+ PWN::AI::Agent::Registry.register(
12
+ name: 'cron_list',
13
+ toolset: 'cron',
14
+ schema: {
15
+ name: 'cron_list',
16
+ description: 'List all pwn-ai cron jobs from ~/.pwn/cron/jobs.yml ' \
17
+ '(id, name, schedule, enabled, last_run, last_status).',
18
+ parameters: { type: 'object', properties: {}, required: [] }
19
+ },
20
+ check: -> { defined?(PWN::Cron) },
21
+ handler: lambda { |_args|
22
+ PWN::Cron.list.map do |id, job|
23
+ {
24
+ id: id,
25
+ name: job[:name],
26
+ schedule: job[:schedule],
27
+ enabled: job[:enabled],
28
+ delivery: job[:delivery],
29
+ prompt: job[:prompt].to_s[0, 120],
30
+ ruby: job[:ruby].to_s[0, 120],
31
+ script: job[:script],
32
+ last_run: job[:last_run],
33
+ last_status: job[:last_status]
34
+ }
35
+ end
36
+ }
37
+ )
38
+
39
+ PWN::AI::Agent::Registry.register(
40
+ name: 'cron_create',
41
+ toolset: 'cron',
42
+ schema: {
43
+ name: 'cron_create',
44
+ description: 'Create a scheduled pwn-ai job. Provide exactly one of ' \
45
+ 'prompt (sent to the active AI engine), ruby (evaluated ' \
46
+ 'in-process), or script (external path). Set ' \
47
+ 'install_crontab:true to also append a system crontab ' \
48
+ 'entry that invokes PWN::Cron.run for this job id.',
49
+ parameters: {
50
+ type: 'object',
51
+ properties: {
52
+ name: { type: 'string', description: 'Human-friendly job name (default job-<id>).' },
53
+ schedule: {
54
+ type: 'string',
55
+ description: 'Cron expression, e.g. "0 * * * *". Required when ' \
56
+ 'install_crontab is true; otherwise informational.'
57
+ },
58
+ prompt: { type: 'string', description: 'pwn-ai prompt to run against the active engine.' },
59
+ ruby: { type: 'string', description: 'Ruby snippet evaluated in TOPLEVEL_BINDING.' },
60
+ script: { type: 'string', description: 'Path to an external executable script.' },
61
+ delivery: { type: 'string', enum: %w[log stdout], default: 'log' },
62
+ enabled: { type: 'boolean', default: true },
63
+ install_crontab: {
64
+ type: 'boolean',
65
+ default: false,
66
+ description: 'Also append a `crontab -l` entry that runs this job id via pwn.'
67
+ }
68
+ },
69
+ required: %w[schedule]
70
+ }
71
+ },
72
+ check: -> { defined?(PWN::Cron) },
73
+ handler: lambda { |args|
74
+ PWN::Cron.create(
75
+ name: args[:name],
76
+ schedule: args[:schedule],
77
+ prompt: args[:prompt],
78
+ ruby: args[:ruby],
79
+ script: args[:script],
80
+ delivery: args[:delivery],
81
+ enabled: args.fetch(:enabled, true),
82
+ install_crontab: args[:install_crontab]
83
+ )
84
+ }
85
+ )
86
+
87
+ PWN::AI::Agent::Registry.register(
88
+ name: 'cron_run',
89
+ toolset: 'cron',
90
+ schema: {
91
+ name: 'cron_run',
92
+ description: 'Execute a cron job immediately by id (or name) and ' \
93
+ 'return { job:, result:, duration:, status: }. Updates ' \
94
+ 'last_run / last_status in jobs.yml.',
95
+ parameters: {
96
+ type: 'object',
97
+ properties: {
98
+ id: { type: 'string', description: 'Job id or job name.' }
99
+ },
100
+ required: %w[id]
101
+ }
102
+ },
103
+ check: -> { defined?(PWN::Cron) },
104
+ handler: lambda { |args|
105
+ PWN::Cron.run(id: args[:id])
106
+ }
107
+ )
108
+
109
+ PWN::AI::Agent::Registry.register(
110
+ name: 'cron_enable',
111
+ toolset: 'cron',
112
+ schema: {
113
+ name: 'cron_enable',
114
+ description: 'Set enabled:true on a cron job by id.',
115
+ parameters: {
116
+ type: 'object',
117
+ properties: { id: { type: 'string' } },
118
+ required: %w[id]
119
+ }
120
+ },
121
+ check: -> { defined?(PWN::Cron) },
122
+ handler: lambda { |args|
123
+ { id: args[:id], job: PWN::Cron.enable(id: args[:id]) }
124
+ }
125
+ )
126
+
127
+ PWN::AI::Agent::Registry.register(
128
+ name: 'cron_disable',
129
+ toolset: 'cron',
130
+ schema: {
131
+ name: 'cron_disable',
132
+ description: 'Set enabled:false on a cron job by id.',
133
+ parameters: {
134
+ type: 'object',
135
+ properties: { id: { type: 'string' } },
136
+ required: %w[id]
137
+ }
138
+ },
139
+ check: -> { defined?(PWN::Cron) },
140
+ handler: lambda { |args|
141
+ { id: args[:id], job: PWN::Cron.disable(id: args[:id]) }
142
+ }
143
+ )
144
+
145
+ PWN::AI::Agent::Registry.register(
146
+ name: 'cron_remove',
147
+ toolset: 'cron',
148
+ schema: {
149
+ name: 'cron_remove',
150
+ description: 'Delete a cron job by id from ~/.pwn/cron/jobs.yml. ' \
151
+ 'Does NOT scrub any system crontab entry — remove that ' \
152
+ 'manually with `crontab -e` if install_crontab was used.',
153
+ parameters: {
154
+ type: 'object',
155
+ properties: { id: { type: 'string' } },
156
+ required: %w[id]
157
+ }
158
+ },
159
+ check: -> { defined?(PWN::Cron) },
160
+ handler: lambda { |args|
161
+ { id: args[:id], removed: PWN::Cron.remove(id: args[:id]) }
162
+ }
163
+ )