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
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module PWN
6
+ module AI
7
+ module Agent
8
+ # PWN::AI::Agent::Introspection is the inward-facing counterpart to
9
+ # PWN::AI::Agent::Extrospection. Where Extrospection looks OUTWARD at
10
+ # the world the agent operates in (host state, toolchain, network,
11
+ # threat-intel), Introspection looks INWARD - it lets pwn hand a
12
+ # request to the active AI engine and reflect on its own artifacts,
13
+ # transcripts, findings, code, or decisions.
14
+ #
15
+ # This module is gated by `PWN::Env[:ai][:introspection]` so that
16
+ # potentially-sensitive local data is never shipped to a remote LLM
17
+ # unless the operator has explicitly opted in via pwn-vault / config.
18
+ #
19
+ # It is the single choke-point every PWN::AI::Agent::* domain agent
20
+ # (Assembly, BurpSuite, GQRX, HackerOne, SAST, VulnGen, ...) routes
21
+ # through when it wants an LLM opinion on locally-produced data, and
22
+ # it is also what PWN::AI::Agent::Learning.reflect uses to distill
23
+ # session transcripts into durable PWN::Memory lessons.
24
+ module Introspection
25
+ # Supported Method Parameters::
26
+ # response = PWN::AI::Agent::Introspection.reflect_on(
27
+ # request: 'required - String - What you want the AI to reflect on',
28
+ # system_role_content: 'optional - context to set up the model behavior for reflection',
29
+ # spinner: 'optional - Boolean - Display spinner during operation (default: false)',
30
+ # suppress_pii_warning: 'optional - Boolean - Suppress PII Warnings (default: false)'
31
+ # )
32
+
33
+ public_class_method def self.reflect_on(opts = {})
34
+ request = opts[:request]
35
+ raise 'ERROR: request must be provided' if request.nil?
36
+
37
+ system_role_content = opts[:system_role_content]
38
+
39
+ spinner = opts[:spinner] || false
40
+
41
+ suppress_pii_warning = opts[:suppress_pii_warning] || false
42
+
43
+ response = nil
44
+
45
+ ai_introspection = PWN::Env[:ai][:introspection]
46
+
47
+ if ai_introspection && request.length.positive?
48
+ engine = PWN::Env[:ai][:active].to_s.downcase.to_sym
49
+ valid_ai_engines = PWN::AI.help.reject { |e| e.downcase == :agent }.map(&:downcase)
50
+ raise "ERROR: Unsupported AI engine. Supported engines are: #{valid_ai_engines}" unless valid_ai_engines.include?(engine)
51
+
52
+ warn "AI Introspection is enabled. Ensure #{engine} has been authorized for use and/or requests are sanitized properly." unless suppress_pii_warning
53
+ response = PWN::AI::Agent::Loop.run(
54
+ request: request.chomp,
55
+ system_role_content: system_role_content,
56
+ enabled_toolsets: [],
57
+ spinner: spinner
58
+ )
59
+ end
60
+
61
+ response
62
+ rescue StandardError => e
63
+ raise e
64
+ end
65
+
66
+ # Author(s):: 0day Inc. <support@0dayinc.com>
67
+
68
+ public_class_method def self.authors
69
+ "AUTHOR(S):
70
+ 0day Inc. <support@0dayinc.com>
71
+ "
72
+ end
73
+
74
+ # Display Usage for this Module
75
+
76
+ public_class_method def self.help
77
+ puts "USAGE:
78
+ #{self}.reflect_on(
79
+ request: 'required - String - What you want the AI to reflect on',
80
+ system_role_content: 'optional - context to set up the model behavior for reflection',
81
+ spinner: 'optional - Boolean - Display spinner during operation (default: false)',
82
+ suppress_pii_warning: 'optional - Boolean - Suppress PII Warnings (default: false)'
83
+ )
84
+
85
+ #{self}.authors
86
+ "
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,379 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+ require 'time'
6
+ require 'digest'
7
+
8
+ module PWN
9
+ module AI
10
+ module Agent
11
+ # PWN::AI::Agent::Learning is the self-improvement engine that closes
12
+ # the pwn-ai feedback loop. It captures task outcomes, mines session
13
+ # transcripts for durable lessons, promotes successful workflows into
14
+ # reusable skills, and prunes / consolidates persistent memory so the
15
+ # agent gets sharper over time instead of accumulating noise.
16
+ #
17
+ # Data flows:
18
+ # Loop.run --(tool telemetry)--> Metrics.record
19
+ # Loop.run --(final answer)----> Learning.auto_reflect (opt-in)
20
+ # model --(tool calls)------> learning_note_outcome / _distill_skill
21
+ # PromptBuilder <----------------- Learning.to_context + Metrics.to_context
22
+ #
23
+ # Everything is file-backed under ~/.pwn so it survives across REPL
24
+ # restarts and is shared by every future session.
25
+ module Learning
26
+ LEARNING_FILE = File.join(Dir.home, '.pwn', 'learning.jsonl')
27
+ MAX_MEMORY_ENTRIES = 200
28
+
29
+ # Supported Method Parameters::
30
+ # entry = PWN::AI::Agent::Learning.note_outcome(
31
+ # task: 'required - short description of what was attempted',
32
+ # success: 'required - Boolean, did the attempt achieve its goal',
33
+ # details: 'optional - free-form notes / error / evidence',
34
+ # session_id: 'optional - PWN::Sessions id this outcome belongs to',
35
+ # tags: 'optional - Array of String labels for later retrieval'
36
+ # )
37
+
38
+ public_class_method def self.note_outcome(opts = {})
39
+ task = opts[:task].to_s
40
+ success = opts[:success] ? true : false
41
+ raise 'ERROR: task is required' if task.strip.empty?
42
+
43
+ entry = {
44
+ id: Digest::SHA256.hexdigest("#{task}-#{Time.now.to_f}")[0, 12],
45
+ task: task,
46
+ success: success,
47
+ details: opts[:details].to_s[0, 2_000],
48
+ session_id: opts[:session_id],
49
+ tags: Array(opts[:tags]).map(&:to_s),
50
+ timestamp: Time.now.utc.iso8601
51
+ }
52
+ FileUtils.mkdir_p(File.dirname(LEARNING_FILE))
53
+ File.open(LEARNING_FILE, 'a') { |f| f.puts(JSON.generate(entry)) }
54
+
55
+ key = :"lesson_#{entry[:id]}"
56
+ cat = :lesson
57
+ val = "#{success ? 'SUCCESS' : 'FAILURE'}: #{task} — #{opts[:details].to_s.strip[0, 200]}"
58
+ PWN::Memory.remember(key: key, value: val, category: cat) if defined?(PWN::Memory)
59
+
60
+ entry
61
+ end
62
+
63
+ # Supported Method Parameters::
64
+ # rows = PWN::AI::Agent::Learning.outcomes(
65
+ # limit: 'optional - max entries returned newest-first (default 50)',
66
+ # success: 'optional - filter by Boolean outcome',
67
+ # tag: 'optional - filter by tag substring'
68
+ # )
69
+
70
+ public_class_method def self.outcomes(opts = {})
71
+ limit = opts[:limit] || 50
72
+ want_ok = opts.key?(:success) ? !opts[:success].nil? && opts[:success] != false : nil
73
+ tag = opts[:tag].to_s.downcase
74
+ return [] unless File.exist?(LEARNING_FILE)
75
+
76
+ rows = File.readlines(LEARNING_FILE).map do |l|
77
+ JSON.parse(l, symbolize_names: true)
78
+ rescue StandardError
79
+ nil
80
+ end
81
+ rows.compact!
82
+ rows.select! { |r| r[:success] == want_ok } unless want_ok.nil?
83
+ rows.select! { |r| Array(r[:tags]).any? { |t| t.to_s.downcase.include?(tag) } } unless tag.empty?
84
+ rows.reverse.first(limit)
85
+ end
86
+
87
+ # Supported Method Parameters::
88
+ # stats = PWN::AI::Agent::Learning.stats
89
+
90
+ public_class_method def self.stats
91
+ rows = outcomes(limit: 10_000)
92
+ total = rows.length
93
+ ok = rows.count { |r| r[:success] }
94
+ skills = defined?(PWN::Skills) && PWN::Skills.is_a?(Hash) ? PWN::Skills.keys.length : 0
95
+ mem = defined?(PWN::Memory) ? PWN::Memory.load.keys.length : 0
96
+ {
97
+ total_outcomes: total,
98
+ successes: ok,
99
+ failures: total - ok,
100
+ success_rate: total.positive? ? (ok.to_f / total).round(3) : 0.0,
101
+ skills_known: skills,
102
+ memory_entries: mem,
103
+ tool_metrics: (Metrics.summary(limit: 5) if defined?(Metrics)),
104
+ extrospection: (Extrospection.stats if defined?(Extrospection))
105
+ }
106
+ end
107
+
108
+ # Supported Method Parameters::
109
+ # ctx = PWN::AI::Agent::Learning.to_context(
110
+ # limit: 'optional - number of recent outcomes to surface (default 5)'
111
+ # )
112
+
113
+ public_class_method def self.to_context(opts = {})
114
+ limit = opts[:limit] || 5
115
+ rows = outcomes(limit: limit)
116
+ return '' if rows.empty?
117
+
118
+ lines = rows.map do |r|
119
+ flag = r[:success] ? '✓' : '✗'
120
+ " #{flag} #{r[:task][0, 100]} (#{r[:timestamp]})"
121
+ end
122
+ s = stats
123
+ hdr = "RECENT OUTCOMES (success_rate=#{(s[:success_rate] * 100).round(1)}% over #{s[:total_outcomes]} attempts)"
124
+ "#{hdr}\n#{lines.join("\n")}\n\n"
125
+ end
126
+
127
+ # Supported Method Parameters::
128
+ # skill = PWN::AI::Agent::Learning.distill_skill(
129
+ # name: 'required - snake_case name for the new skill',
130
+ # session_id: 'optional - PWN::Sessions id to mine (uses its transcript)',
131
+ # content: 'optional - explicit markdown body; overrides transcript mining',
132
+ # references: 'optional - Array of reference URLs / CWE / CVE / ATT&CK ids'
133
+ # )
134
+
135
+ public_class_method def self.distill_skill(opts = {})
136
+ name = opts[:name].to_s.gsub(/[^a-z0-9_-]/i, '_')
137
+ raise 'ERROR: name is required' if name.empty?
138
+
139
+ body = opts[:content].to_s
140
+ body = build_skill_from_session(session_id: opts[:session_id], name: name) if body.strip.empty? && opts[:session_id]
141
+ raise 'ERROR: content or session_id is required' if body.strip.empty?
142
+
143
+ refs = Array(opts[:references]).map(&:to_s).map(&:strip).reject(&:empty?).uniq
144
+ unless refs.empty?
145
+ body = "---\nreferences:\n#{refs.map { |r| " - #{r}" }.join("\n")}\n---\n#{body}" unless body.start_with?("---\n")
146
+ body = "#{body.rstrip}\n\n## References\n#{refs.map { |r| "- #{r}" }.join("\n")}\n" unless body =~ /^\#{1,3}\s*References\s*$/i
147
+ end
148
+
149
+ dir = skills_dir
150
+ FileUtils.mkdir_p(dir)
151
+ path = File.join(dir, "#{name}.md")
152
+ File.write(path, body)
153
+ PWN::Config.load_skills(pwn_skills_path: dir) if defined?(PWN::Config) && PWN::Config.respond_to?(:load_skills)
154
+ note_outcome(task: "distill_skill:#{name}", success: true, details: "Saved #{path}", tags: %w[skill auto])
155
+ { saved: true, name: name, path: path, bytes: body.bytesize, references: refs }
156
+ end
157
+
158
+ # Supported Method Parameters::
159
+ # report = PWN::AI::Agent::Learning.reflect(
160
+ # session_id: 'required - PWN::Sessions id to analyse',
161
+ # dry_run: 'optional - when true, do not write to Memory/Skills (default false)'
162
+ # )
163
+ #
164
+ # Uses PWN::AI::Agent::Introspection (when available) to LLM-summarise the
165
+ # session into structured lessons. Falls back to a heuristic
166
+ # extractor when introspection is disabled so learning never stops.
167
+
168
+ public_class_method def self.reflect(opts = {})
169
+ session_id = opts[:session_id]
170
+ dry_run = opts[:dry_run] ? true : false
171
+ raise 'ERROR: session_id is required' if session_id.to_s.empty?
172
+
173
+ transcript = PWN::Sessions.load(session_id: session_id)
174
+ return { session_id: session_id, lessons: [], reason: 'empty transcript' } if transcript.empty?
175
+
176
+ lessons = introspective_lessons(transcript: transcript)
177
+ lessons = heuristic_lessons(transcript: transcript) if lessons.empty?
178
+
179
+ saved = []
180
+ lessons.each do |l|
181
+ next if l.to_s.strip.empty?
182
+
183
+ key = :"reflect_#{session_id}_#{Digest::SHA256.hexdigest(l)[0, 8]}"
184
+ PWN::Memory.remember(key: key, value: l, category: :lesson) unless dry_run
185
+ saved << { key: key, lesson: l }
186
+ end
187
+ consolidate unless dry_run
188
+
189
+ { session_id: session_id, lessons: saved, count: saved.length, dry_run: dry_run }
190
+ end
191
+
192
+ # Supported Method Parameters::
193
+ # PWN::AI::Agent::Learning.auto_reflect(
194
+ # session_id: 'required - id of the just-completed session',
195
+ # request: 'optional - original user request (for outcome logging)',
196
+ # final: 'optional - final assistant answer (for outcome logging)'
197
+ # )
198
+ #
199
+ # Called by Loop.run when PWN::Env[:ai][:agent][:auto_reflect] is
200
+ # truthy. Never raises — learning must not break the primary loop.
201
+
202
+ public_class_method def self.auto_reflect(opts = {})
203
+ session_id = opts[:session_id]
204
+ return unless session_id
205
+ return unless auto_reflect_enabled?
206
+
207
+ note_outcome(
208
+ task: opts[:request].to_s[0, 120],
209
+ success: !opts[:final].to_s.strip.empty?,
210
+ details: opts[:final].to_s[0, 300],
211
+ session_id: session_id,
212
+ tags: %w[auto loop]
213
+ )
214
+ reflect(session_id: session_id)
215
+ Extrospection.auto_extrospect(session_id: session_id) if defined?(Extrospection)
216
+ rescue StandardError => e
217
+ warn "[pwn-ai/learning] auto_reflect swallowed: #{e.class}: #{e.message}"
218
+ nil
219
+ end
220
+
221
+ # Supported Method Parameters::
222
+ # removed = PWN::AI::Agent::Learning.consolidate(
223
+ # max_entries: 'optional - hard cap on PWN::Memory size (default MAX_MEMORY_ENTRIES)'
224
+ # )
225
+ #
226
+ # Deduplicates near-identical lesson values and prunes the oldest
227
+ # entries once the cap is exceeded so the injected MEMORY block
228
+ # stays high-signal.
229
+
230
+ public_class_method def self.consolidate(opts = {})
231
+ cap = opts[:max_entries] || MAX_MEMORY_ENTRIES
232
+ return { removed: 0 } unless defined?(PWN::Memory)
233
+
234
+ mem = PWN::Memory.load
235
+ seen = {}
236
+ removed = []
237
+ mem.each do |k, v|
238
+ sig = Digest::SHA256.hexdigest(v[:value].to_s.strip.downcase)[0, 16]
239
+ if seen[sig]
240
+ removed << k
241
+ else
242
+ seen[sig] = k
243
+ end
244
+ end
245
+ removed.each { |k| mem.delete(k) }
246
+
247
+ if mem.size > cap
248
+ sorted = mem.sort_by { |_k, v| v[:timestamp].to_s }
249
+ drop = sorted.first(mem.size - cap).map(&:first)
250
+ drop.each { |k| mem.delete(k) }
251
+ removed.concat(drop)
252
+ end
253
+ PWN::Memory.save(mem: mem)
254
+ { removed: removed.length, remaining: mem.size }
255
+ end
256
+
257
+ # Supported Method Parameters::
258
+ # PWN::AI::Agent::Learning.reset
259
+
260
+ public_class_method def self.reset
261
+ FileUtils.rm_f(LEARNING_FILE)
262
+ { cleared: true }
263
+ end
264
+
265
+ # -------------------------------------------------------------
266
+ # privates
267
+ # -------------------------------------------------------------
268
+
269
+ private_class_method def self.auto_reflect_enabled?
270
+ return false unless defined?(PWN::Env) && PWN::Env.is_a?(Hash)
271
+
272
+ PWN::Env.dig(:ai, :agent, :auto_reflect) ? true : false
273
+ rescue StandardError
274
+ false
275
+ end
276
+
277
+ private_class_method def self.skills_dir
278
+ if defined?(PWN::Config) && PWN::Config.respond_to?(:pwn_skills_path)
279
+ PWN::Config.pwn_skills_path
280
+ else
281
+ File.join(Dir.home, '.pwn', 'skills')
282
+ end
283
+ rescue StandardError
284
+ File.join(Dir.home, '.pwn', 'skills')
285
+ end
286
+
287
+ private_class_method def self.transcript_text(opts = {})
288
+ transcript = opts[:transcript] || []
289
+ transcript.map do |e|
290
+ "[#{e[:role]}] #{e[:content].to_s.gsub(/\s+/, ' ')[0, 400]}"
291
+ end.join("\n")
292
+ end
293
+
294
+ private_class_method def self.introspective_lessons(opts = {})
295
+ transcript = opts[:transcript] || []
296
+ return [] unless defined?(PWN::AI::Agent::Introspection)
297
+ return [] unless defined?(PWN::Env) && PWN::Env.is_a?(Hash) && PWN::Env.dig(:ai, :introspection)
298
+
299
+ req = "Analyse this pwn-ai session transcript and emit up to 5 durable, generalizable lessons (one per line, no numbering, imperative voice) that would make future runs faster or more reliable. Focus on tool selection, error recovery, and target-agnostic technique. Ignore trivia.\n\nTRANSCRIPT:\n#{transcript_text(transcript: transcript)}"
300
+ resp = PWN::AI::Agent::Introspection.reflect_on(request: req, suppress_pii_warning: true)
301
+ resp.to_s.lines.map(&:strip).reject(&:empty?).first(5)
302
+ rescue StandardError
303
+ []
304
+ end
305
+
306
+ private_class_method def self.heuristic_lessons(opts = {})
307
+ transcript = opts[:transcript] || []
308
+ lessons = []
309
+ transcript.each do |e|
310
+ c = e[:content].to_s
311
+ next unless e[:role].to_s == 'tool'
312
+
313
+ if c.include?('"success":false') || c.match?(/error|Error|ERROR/)
314
+ tool = c[/^(\w+) →/, 1] || c[/"error":"([^"]{5,120})"/, 1] || 'tool'
315
+ lessons << "Avoid repeating failure pattern from #{tool}: #{c[0, 160]}"
316
+ end
317
+ end
318
+ if lessons.empty?
319
+ asst = transcript.rfind { |e| e[:role].to_s == 'assistant' }
320
+ lessons << "Approach that worked: #{asst[:content].to_s.strip[0, 200]}" if asst && !asst[:content].to_s.strip.empty?
321
+ end
322
+ lessons.uniq.first(5)
323
+ end
324
+
325
+ private_class_method def self.build_skill_from_session(opts = {})
326
+ session_id = opts[:session_id]
327
+ name = opts[:name]
328
+ transcript = PWN::Sessions.load(session_id: session_id)
329
+ steps = transcript.select { |e| %w[tool assistant].include?(e[:role].to_s) }
330
+ .map { |e| "- **#{e[:role]}**: #{e[:content].to_s.strip[0, 300]}" }
331
+ user = transcript.find { |e| e[:role].to_s == 'user' }
332
+ goal = user ? user[:content].to_s.strip[0, 200] : name
333
+ <<~MD
334
+ # #{name.tr('_-', ' ').capitalize}
335
+
336
+ _Auto-distilled by PWN::AI::Agent::Learning from session `#{session_id}` on #{Time.now.utc.iso8601}._
337
+
338
+ ## Goal
339
+ #{goal}
340
+
341
+ ## Observed Procedure
342
+ #{steps.join("\n")}
343
+
344
+ ## Notes
345
+ Refine this skill by editing #{name}.md under ~/.pwn/skills.
346
+ MD
347
+ end
348
+
349
+ # Author(s):: 0day Inc. <support@0dayinc.com>
350
+
351
+ public_class_method def self.authors
352
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
353
+ end
354
+
355
+ # Display Usage for this Module
356
+
357
+ public_class_method def self.help
358
+ puts <<~USAGE
359
+ USAGE:
360
+ PWN::AI::Agent::Learning.note_outcome(task: 'nmap sweep 10.0.0.0/24', success: true, details: '12 hosts up')
361
+ PWN::AI::Agent::Learning.outcomes(limit: 20, success: false)
362
+ PWN::AI::Agent::Learning.reflect(session_id: sid) # LLM or heuristic → PWN::Memory
363
+ PWN::AI::Agent::Learning.auto_reflect(session_id: sid, request: req, final: text)
364
+ PWN::AI::Agent::Learning.distill_skill(name: 'quick_recon', session_id: sid)
365
+ PWN::AI::Agent::Learning.consolidate(max_entries: 200) # dedupe + prune Memory
366
+ PWN::AI::Agent::Learning.to_context(limit: 5) # injected by PromptBuilder
367
+ PWN::AI::Agent::Learning.stats
368
+ PWN::AI::Agent::Learning.reset
369
+
370
+ Enable end-of-run auto-learning with:
371
+ PWN::Env[:ai][:agent][:auto_reflect] = true
372
+
373
+ #{self}.authors
374
+ USAGE
375
+ end
376
+ end
377
+ end
378
+ end
379
+ end
@@ -49,6 +49,32 @@ module PWN
49
49
  DEFAULT_MAX_ITERS
50
50
  end
51
51
 
52
+ private_class_method def self.record_metrics(opts = {})
53
+ name = opts[:name]
54
+ started = opts[:started]
55
+ raw = opts[:raw].to_s
56
+ ok = raw.include?('"success":true')
57
+ err = raw[/"error":"([^"]{1,300})"/, 1]
58
+ dur = started ? (Time.now - started) : 0.0
59
+ Metrics.record(name: name, success: ok, duration: dur, error: err) if defined?(Metrics)
60
+ rescue StandardError
61
+ nil
62
+ end
63
+
64
+ # Stash the active session_id under PWN::Env[:ai][:session_id] so
65
+ # tool handlers (sessions_current) can discover it without a Pry
66
+ # dependency. PWN::Env is frozen at the top level but [:ai] is a
67
+ # nested mutable Hash on all supported config paths — swallow if not.
68
+ private_class_method def self.expose_current_session(opts = {})
69
+ sid = opts[:session_id]
70
+ return unless sid && defined?(PWN::Env) && PWN::Env.is_a?(Hash)
71
+
72
+ ai = PWN::Env[:ai]
73
+ ai[:session_id] = sid if ai.is_a?(Hash) && !ai.frozen?
74
+ rescue StandardError
75
+ nil
76
+ end
77
+
52
78
  private_class_method def self.append_session(opts = {})
53
79
  session_id = opts[:session_id]
54
80
  return unless session_id && defined?(PWN::Sessions)
@@ -145,6 +171,7 @@ module PWN
145
171
  system_role_content = opts[:system_role_content] ||= PWN::AI::Agent::PromptBuilder.build(session_id: session_id)
146
172
 
147
173
  Registry.discover
174
+ expose_current_session(session_id: session_id)
148
175
 
149
176
  tools = Registry.definitions(enabled: opts[:enabled_toolsets])
150
177
  messages = [
@@ -163,13 +190,16 @@ module PWN
163
190
  if calls.empty?
164
191
  text = msg[:content].to_s
165
192
  append_session(session_id: session_id, role: 'assistant', content: text)
193
+ Learning.auto_reflect(session_id: session_id, request: request, final: text) if defined?(Learning)
166
194
  return text
167
195
  end
168
196
 
169
197
  calls.each do |tc|
170
- name = tc.dig(:function, :name).to_s
171
- entry = Registry.lookup(name: name)
172
- raw = Dispatch.call(tool_call: tc)
198
+ name = tc.dig(:function, :name).to_s
199
+ entry = Registry.lookup(name: name)
200
+ started = Time.now
201
+ raw = Dispatch.call(tool_call: tc)
202
+ record_metrics(name: name, started: started, raw: raw)
173
203
  result = Result.condition(content: raw, entry: entry)
174
204
 
175
205
  on_tool&.call(name, tc.dig(:function, :arguments), result)
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+ require 'time'
6
+
7
+ module PWN
8
+ module AI
9
+ module Agent
10
+ # PWN::AI::Agent::Metrics is the telemetry layer of the pwn-ai
11
+ # learning loop. Every tool dispatch performed by
12
+ # PWN::AI::Agent::Loop is recorded here (name, success, duration,
13
+ # last error) and persisted to ~/.pwn/metrics.json.
14
+ #
15
+ # PromptBuilder re-injects a compact effectiveness summary into the
16
+ # system prompt on every turn, so the model gains awareness of which
17
+ # tools historically succeed vs. fail on THIS host and can adapt its
18
+ # tool selection accordingly. This is one half of the closed
19
+ # feedback loop that lets pwn-ai continuously make itself smarter
20
+ # (the other half is PWN::AI::Agent::Learning).
21
+ module Metrics
22
+ METRICS_FILE = File.join(Dir.home, '.pwn', 'metrics.json')
23
+
24
+ # Supported Method Parameters::
25
+ # metrics = PWN::AI::Agent::Metrics.load
26
+
27
+ public_class_method def self.load
28
+ FileUtils.mkdir_p(File.dirname(METRICS_FILE))
29
+ return { tools: {}, updated_at: nil } unless File.exist?(METRICS_FILE)
30
+
31
+ JSON.parse(File.read(METRICS_FILE), symbolize_names: true)
32
+ rescue StandardError
33
+ { tools: {}, updated_at: nil }
34
+ end
35
+
36
+ # Supported Method Parameters::
37
+ # PWN::AI::Agent::Metrics.save(
38
+ # metrics: 'required - Hash returned by .load / mutated in place'
39
+ # )
40
+
41
+ public_class_method def self.save(opts = {})
42
+ metrics = opts[:metrics] ||= { tools: {} }
43
+ metrics[:updated_at] = Time.now.utc.iso8601
44
+ FileUtils.mkdir_p(File.dirname(METRICS_FILE))
45
+ File.write(METRICS_FILE, JSON.pretty_generate(metrics))
46
+ metrics
47
+ end
48
+
49
+ # Supported Method Parameters::
50
+ # PWN::AI::Agent::Metrics.record(
51
+ # name: 'required - tool name that was dispatched',
52
+ # success: 'required - Boolean, did the handler complete without error',
53
+ # duration: 'optional - Float seconds the dispatch took',
54
+ # error: 'optional - String error message when success is false'
55
+ # )
56
+
57
+ public_class_method def self.record(opts = {})
58
+ name = opts[:name].to_s
59
+ success = opts[:success] ? true : false
60
+ duration = opts[:duration].to_f
61
+ error = opts[:error]
62
+ return if name.empty?
63
+
64
+ metrics = load
65
+ metrics[:tools] ||= {}
66
+ key = name.to_sym
67
+ t = metrics[:tools][key] ||= {
68
+ calls: 0, ok: 0, fail: 0, total_duration: 0.0,
69
+ last_error: nil, last_at: nil
70
+ }
71
+ t[:calls] += 1
72
+ t[:ok] += 1 if success
73
+ t[:fail] += 1 unless success
74
+ t[:total_duration] += duration
75
+ t[:last_error] = error.to_s[0, 300] if error && !success
76
+ t[:last_at] = Time.now.utc.iso8601
77
+ save(metrics: metrics)
78
+ t
79
+ end
80
+
81
+ # Supported Method Parameters::
82
+ # rows = PWN::AI::Agent::Metrics.summary(
83
+ # limit: 'optional - cap number of tools returned (default 25)'
84
+ # )
85
+
86
+ public_class_method def self.summary(opts = {})
87
+ limit = opts[:limit] || 25
88
+ tools = load[:tools] || {}
89
+ rows = tools.map do |name, t|
90
+ calls = t[:calls].to_i
91
+ ok = t[:ok].to_i
92
+ rate = calls.positive? ? (ok.to_f / calls).round(3) : 0.0
93
+ avg = calls.positive? ? (t[:total_duration].to_f / calls).round(3) : 0.0
94
+ {
95
+ name: name.to_s,
96
+ calls: calls,
97
+ success_rate: rate,
98
+ avg_duration: avg,
99
+ last_error: t[:last_error],
100
+ last_at: t[:last_at]
101
+ }
102
+ end
103
+ rows.sort_by { |r| [-r[:calls], -r[:success_rate]] }.first(limit)
104
+ end
105
+
106
+ # Supported Method Parameters::
107
+ # ctx = PWN::AI::Agent::Metrics.to_context(
108
+ # limit: 'optional - cap number of tools included (default 8)'
109
+ # )
110
+
111
+ public_class_method def self.to_context(opts = {})
112
+ limit = opts[:limit] || 8
113
+ rows = summary(limit: limit)
114
+ return '' if rows.empty?
115
+
116
+ lines = rows.map do |r|
117
+ err = r[:last_error] ? " last_err=#{r[:last_error][0, 60]}" : ''
118
+ " - #{r[:name]}: calls=#{r[:calls]} success=#{(r[:success_rate] * 100).round(1)}% avg=#{r[:avg_duration]}s#{err}"
119
+ end
120
+ "TOOL EFFECTIVENESS (historical, adapt tool choice accordingly)\n#{lines.join("\n")}\n\n"
121
+ end
122
+
123
+ # Supported Method Parameters::
124
+ # PWN::AI::Agent::Metrics.reset
125
+
126
+ public_class_method def self.reset
127
+ FileUtils.rm_f(METRICS_FILE)
128
+ { tools: {}, updated_at: nil }
129
+ end
130
+
131
+ # Author(s):: 0day Inc. <support@0dayinc.com>
132
+
133
+ public_class_method def self.authors
134
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
135
+ end
136
+
137
+ # Display Usage for this Module
138
+
139
+ public_class_method def self.help
140
+ puts <<~USAGE
141
+ USAGE:
142
+ PWN::AI::Agent::Metrics.record(name: 'shell', success: true, duration: 0.42)
143
+ PWN::AI::Agent::Metrics.summary(limit: 10)
144
+ PWN::AI::Agent::Metrics.to_context(limit: 8) # injected by PromptBuilder
145
+ PWN::AI::Agent::Metrics.reset
146
+ PWN::AI::Agent::Metrics.load
147
+ PWN::AI::Agent::Metrics.save(metrics: hash)
148
+
149
+ #{self}.authors
150
+ USAGE
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end