pwn 0.5.702 → 0.5.704

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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +7 -0
  3. data/build_gem.sh +1 -1
  4. data/documentation/General-PWN-Usage.md +2 -1
  5. data/documentation/Installation.md +5 -3
  6. data/documentation/pwn-REPL.md +2 -1
  7. data/etc/default_skills/pwn/ai/agent/learning/SKILL.md +4 -0
  8. data/etc/default_skills/pwn/ai/agent/loop/SKILL.md +4 -0
  9. data/etc/default_skills/pwn/ai/agent/mistakes/SKILL.md +4 -0
  10. data/etc/default_skills/pwn/ai/agent/open_goal/SKILL.md +3 -0
  11. data/etc/default_skills/pwn/ai/agent/policy/SKILL.md +11 -0
  12. data/etc/default_skills/pwn/ai/agent/prompt_cache/SKILL.md +1 -0
  13. data/etc/default_skills/pwn/ai/agent/reward/SKILL.md +1 -0
  14. data/etc/default_skills/pwn/ai/agent/task_summarizer/SKILL.md +12 -0
  15. data/etc/default_skills/pwn/ai/agent/tool_guard/SKILL.md +9 -0
  16. data/etc/default_skills/pwn/ai/agent/turn_finalizer/SKILL.md +5 -0
  17. data/etc/default_skills/pwn/ai/http_retry/SKILL.md +1 -0
  18. data/etc/default_skills/pwn/banner/matrix/SKILL.md +1 -0
  19. data/etc/default_skills/pwn/cron/SKILL.md +7 -0
  20. data/etc/default_skills/pwn/ffi/SKILL.md +1 -0
  21. data/etc/default_skills/pwn/ffi/adalm_pluto/SKILL.md +2 -0
  22. data/etc/default_skills/pwn/ffi/fftw/SKILL.md +10 -0
  23. data/etc/default_skills/pwn/ffi/hack_rf/SKILL.md +22 -0
  24. data/etc/default_skills/pwn/ffi/liquid/SKILL.md +33 -0
  25. data/etc/default_skills/pwn/ffi/rtl_sdr/SKILL.md +18 -0
  26. data/etc/default_skills/pwn/ffi/soapy_sdr/SKILL.md +24 -0
  27. data/etc/default_skills/pwn/ffi/stdio/SKILL.md +4 -0
  28. data/etc/default_skills/pwn/ffi/volk/SKILL.md +17 -0
  29. data/etc/default_skills/pwn/memory/SKILL.md +4 -0
  30. data/etc/default_skills/pwn/memory_index/SKILL.md +1 -0
  31. data/etc/default_skills/pwn/migrate/SKILL.md +1 -0
  32. data/etc/default_skills/pwn/module_skills/SKILL.md +2 -0
  33. data/etc/default_skills/pwn/plugins/log/SKILL.md +16 -0
  34. data/etc/default_skills/pwn/plugins/pony/SKILL.md +2 -0
  35. data/etc/default_skills/pwn/plugins/repl/SKILL.md +4 -0
  36. data/etc/default_skills/pwn/plugins/tty_spinner/SKILL.md +1 -0
  37. data/etc/default_skills/pwn/plugins/vault/SKILL.md +1 -0
  38. data/etc/default_skills/pwn/sdr/decoder/adsb/SKILL.md +1 -0
  39. data/etc/default_skills/pwn/sdr/decoder/base/SKILL.md +1 -0
  40. data/etc/default_skills/pwn/sdr/decoder/dsp/SKILL.md +3 -0
  41. data/etc/default_skills/pwn/sdr/decoder/flex/SKILL.md +1 -0
  42. data/etc/default_skills/pwn/sessions/SKILL.md +1 -0
  43. data/etc/default_skills/pwn/version/SKILL.md +1 -1
  44. data/lib/pwn/ai/agent/loop.rb +31 -10
  45. data/lib/pwn/ai/agent/tool_guard.rb +24 -13
  46. data/lib/pwn/config.rb +90 -91
  47. data/lib/pwn/module_skills.rb +71 -1
  48. data/lib/pwn/plugins/github.rb +1 -1
  49. data/lib/pwn/plugins/log.rb +71 -8
  50. data/lib/pwn/plugins/repl.rb +35 -3
  51. data/lib/pwn/plugins/tty_spinner.rb +1 -1
  52. data/lib/pwn/plugins/zaproxy.rb +1 -1
  53. data/lib/pwn/version.rb +1 -1
  54. data/spec/integration/config_spec.rb +29 -1
  55. data/spec/lib/pwn/ai/agent/loop_spec.rb +12 -0
  56. data/spec/lib/pwn/ai/agent/tools/ruby_eval_spec.rb +13 -0
  57. data/spec/lib/pwn/module_skills_spec.rb +12 -0
  58. data/spec/lib/pwn/plugins/log_spec.rb +88 -9
  59. data/spec/lib/pwn/plugins/repl_pwn_vault_spec.rb +37 -1
  60. data/spec/lib/pwn/plugins/repl_spec.rb +16 -1
  61. data/third_party/pwn_rdoc.jsonl +15 -2
  62. metadata +1 -1
@@ -144,6 +144,15 @@ module PWN
144
144
  )
145
145
  end
146
146
 
147
+ private_class_method def self.wait_trace_step!(opts = {})
148
+ return unless defined?(PWN::Plugins::Log) && PWN::Plugins::Log.respond_to?(:wait_trace_step!)
149
+
150
+ PWN::Plugins::Log.wait_trace_step!(
151
+ label: opts[:label],
152
+ nested: opts[:nested]
153
+ )
154
+ end
155
+
147
156
  private_class_method def self.start_debug_session(opts = {})
148
157
  return unless debug_on?(opts)
149
158
  return unless defined?(PWN::Plugins::Log)
@@ -153,13 +162,14 @@ module PWN
153
162
  return
154
163
  end
155
164
 
156
- unless PWN::Plugins::Log.debug_enabled?
157
- want_trace = opts[:trace] == true
158
- begin
159
- want_trace ||= PWN::Env.dig(:ai, :agent, :debug_trace) == true
160
- rescue StandardError
161
- nil
162
- end
165
+ want_trace = opts[:trace] == true
166
+ begin
167
+ want_trace ||= defined?(Pry) && Pry.config.respond_to?(:pwn_ai_trace) && Pry.config.pwn_ai_trace
168
+ rescue StandardError
169
+ nil
170
+ end
171
+ want_trace ||= PWN::Plugins::Log.trace_enabled? if PWN::Plugins::Log.respond_to?(:trace_enabled?)
172
+ if !PWN::Plugins::Log.debug_enabled? || want_trace
163
173
  PWN::Plugins::Log.start_debug(
164
174
  tee: opts[:debug_tee] || $stdout,
165
175
  session_id: opts[:session_id],
@@ -934,10 +944,14 @@ module PWN
934
944
  nil
935
945
  end
936
946
 
947
+ PAYLOAD_SHA256 = Digest::SHA256
948
+
937
949
  private_class_method def self.payload_sig(opts = {})
938
- Digest::SHA256.hexdigest("#{opts[:name]}|#{opts[:args]}")[0, 16]
950
+ blob = "#{opts[:name]}|#{opts[:args]}"
951
+ PAYLOAD_SHA256.hexdigest(blob)[0, 16]
939
952
  rescue StandardError
940
- "nosig-#{opts[:name]}"
953
+ key = "#{opts[:name]}|#{opts[:args]}"
954
+ "#{opts[:name]}-#{key.hash.abs.to_s(16)[0, 12]}"
941
955
  end
942
956
 
943
957
  private_class_method def self.note_same_payload!(opts = {})
@@ -1742,7 +1756,12 @@ module PWN
1742
1756
  what\s+did\s+you\s+(?:just\s+)?(?:say|answer|reply|respond)|
1743
1757
  what\s+(?:was|is)\s+your\s+(?:last|previous|prior)\s+(?:answer|response|reply)|
1744
1758
  (?:without\s+looking\s+up).{0,40}(?:session|discussing|talking)|
1745
- (?:from\s+)?(?:memory|context|earlier|previously|prior\s+turn)|
1759
+ from\s+(?:(?:your|my|the)\s+)?(?:memory|context)|
1760
+ in\s+your\s+memory|
1761
+ (?:your|my)\s+memory\s+(?:of|about)|
1762
+ earlier\s+in\s+(?:this\s+)?(?:session|chat|conversation|turn)|
1763
+ previously\s+in\s+(?:this\s+)?(?:session|chat|conversation)|
1764
+ prior\s+turn|
1746
1765
  (?:do\s+you\s+)?remember\s+what\s+(?:i|you)|
1747
1766
  recall\s+(?:what|my|your|the\s+last)|
1748
1767
  last\s+thing\s+(?:i|you)\s+said
@@ -2619,6 +2638,7 @@ module PWN
2619
2638
  end
2620
2639
  engine_s += (Time.now - t0)
2621
2640
  PWN::Plugins::TTYSpinner.halt_all! if defined?(PWN::Plugins::TTYSpinner)
2641
+ wait_trace_step!(label: 'engine', nested: nested)
2622
2642
  if msg.nil?
2623
2643
  task_summary_flush!(state: ts_state, on_tool: on_tool)
2624
2644
  debug_progress(msg: 'engine returned no message')
@@ -2766,6 +2786,7 @@ module PWN
2766
2786
 
2767
2787
  on_tool&.call(name, args, result)
2768
2788
  debug_tool_io!(name: name, args: args, result: result)
2789
+ wait_trace_step!(label: "tool #{name}", nested: nested)
2769
2790
  task_summary_record!(state: ts_state, name: name, args: args, result: result, on_tool: on_tool)
2770
2791
 
2771
2792
  messages << {
@@ -60,23 +60,34 @@ module PWN
60
60
  end
61
61
 
62
62
  # RestClient uses HTTP::CookieJar. pwn_eval in TOPLEVEL_BINDING can
63
- # assign HTTP = "/path/http" (a mkdir) and then every provider hop
64
- # TypeErrors: "path is not a class/module".
63
+ # assign HTTP = "/path/http" or Digest = "(self.we" and then every
64
+ # provider hop / payload_sig TypeErrors.
65
+ CORE_CONSTS = %i[HTTP Digest JSON URI Timeout].freeze
66
+
65
67
  public_class_method def self.protect_http!
66
- if Object.const_defined?(:HTTP, false)
67
- cur = Object.const_get(:HTTP)
68
- @http_mod = cur if cur.is_a?(Module) && @http_mod.nil?
69
- return cur if cur.is_a?(Module)
68
+ protect_core_constants!
69
+ end
70
+
71
+ public_class_method def self.protect_core_constants!
72
+ @core_mods ||= {}
73
+ CORE_CONSTS.each do |name|
74
+ if Object.const_defined?(name, false)
75
+ cur = Object.const_get(name, false)
76
+ @core_mods[name] = cur if cur.is_a?(Module) && @core_mods[name].nil?
77
+ next if cur.is_a?(Module)
78
+
79
+ Object.send(:remove_const, name)
80
+ end
81
+ next unless @core_mods[name].is_a?(Module)
82
+ next if Object.const_defined?(name, false) && Object.const_get(name, false).equal?(@core_mods[name])
70
83
 
71
- Object.send(:remove_const, :HTTP)
84
+ Object.const_set(name, @core_mods[name])
72
85
  end
73
- if @http_mod.is_a?(Module)
74
- Object.const_set(:HTTP, @http_mod)
75
- return @http_mod
86
+ unless Object.const_defined?(:HTTP, false) && Object.const_get(:HTTP, false).is_a?(Module)
87
+ require 'http/cookie_jar'
88
+ @core_mods[:HTTP] = Object.const_get(:HTTP) if Object.const_defined?(:HTTP) && Object.const_get(:HTTP).is_a?(Module)
76
89
  end
77
- require 'http/cookie_jar'
78
- @http_mod = Object.const_get(:HTTP) if Object.const_defined?(:HTTP) && Object.const_get(:HTTP).is_a?(Module)
79
- @http_mod
90
+ @core_mods
80
91
  rescue StandardError
81
92
  nil
82
93
  end
data/lib/pwn/config.rb CHANGED
@@ -849,9 +849,11 @@ module PWN
849
849
  end
850
850
 
851
851
  # Seed bundled skills into ~/.pwn/skills (or pwn_skills_path:).
852
- # Missing names are copied from etc/default_skills (or a one-level
853
- # category subdir such as productivity/<name>). Existing SKILL.md
854
- # files are left alone so operator edits survive upgrades.
852
+ # Every SKILL.md under etc/default_skills is copied, preserving
853
+ # relative path. SOP SKILL.md files are left alone so operator
854
+ # edits survive upgrades. Generated module skills under pwn/ are
855
+ # overwritten by ModuleSkills.install. scripts/ are never copied
856
+ # from the gem templates.
855
857
  public_class_method def self.install_default_skills(opts = {})
856
858
  root = opts[:pwn_skills_path] || pwn_skills_path
857
859
  src_root = opts[:source] || default_skills_dir
@@ -859,33 +861,17 @@ module PWN
859
861
 
860
862
  FileUtils.mkdir_p(root)
861
863
  seeded = []
862
- default_skill_names.each do |name|
863
- src_dir = default_skill_src_dir(source: src_root, name: name)
864
- dest = File.join(root, name, SKILL_ENTRY)
865
- src = File.join(src_dir, SKILL_ENTRY)
866
- next unless File.file?(src)
864
+ each_skill_md(root: src_root) do |src, rel_dir|
865
+ next if module_skill_rel?(rel: rel_dir)
867
866
 
867
+ dest_dir = File.join(root, rel_dir)
868
+ dest = File.join(dest_dir, SKILL_ENTRY)
868
869
  unless File.file?(dest)
869
- FileUtils.mkdir_p(File.dirname(dest))
870
+ FileUtils.mkdir_p(dest_dir)
870
871
  FileUtils.cp(src, dest)
871
- seeded << { name: name, path: dest }
872
- end
873
-
874
- # Copy missing reference files (never overwrite operator edits).
875
- ref_src = File.join(src_dir, 'references')
876
- next unless Dir.exist?(ref_src)
877
-
878
- ref_dest = File.join(root, name, 'references')
879
- FileUtils.mkdir_p(ref_dest)
880
- Dir.children(ref_src).each do |base|
881
- from = File.join(ref_src, base)
882
- next unless File.file?(from)
883
-
884
- to = File.join(ref_dest, base)
885
- next if File.file?(to)
886
-
887
- FileUtils.cp(from, to)
872
+ seeded << { name: rel_dir, path: dest }
888
873
  end
874
+ copy_missing_references(source_dir: File.dirname(src), dest_dir: dest_dir)
889
875
  end
890
876
  if defined?(PWN::ModuleSkills) && PWN::ModuleSkills.respond_to?(:install)
891
877
  PWN::ModuleSkills.install(
@@ -899,16 +885,82 @@ module PWN
899
885
  []
900
886
  end
901
887
 
902
- private_class_method def self.default_skill_src_dir(opts = {})
903
- src_root = opts[:source].to_s
904
- name = opts[:name].to_s
905
- direct = File.join(src_root, name)
906
- return direct if File.file?(File.join(direct, SKILL_ENTRY))
888
+ private_class_method def self.each_skill_md(opts = {})
889
+ root = opts[:root].to_s.chomp('/')
890
+ return if root.empty? || !Dir.exist?(root)
891
+
892
+ Dir.glob(File.join(root, '**', SKILL_ENTRY)).each do |path|
893
+ next unless File.file?(path)
894
+ next if path.include?('/references/')
895
+ next if path.include?('/scripts/')
907
896
 
908
- nested = Dir.glob(File.join(src_root, '*', name, SKILL_ENTRY)).min
909
- return File.dirname(nested) if nested
897
+ rel_dir = File.dirname(path).delete_prefix("#{root}/")
898
+ next if rel_dir.empty? || rel_dir == '.'
899
+
900
+ yield path, rel_dir
901
+ end
902
+ end
903
+
904
+ private_class_method def self.module_skill_rel?(opts = {})
905
+ rel = opts[:rel].to_s
906
+ rel == 'pwn' || rel.start_with?('pwn/')
907
+ end
910
908
 
911
- direct
909
+ private_class_method def self.copy_missing_references(opts = {})
910
+ ref_src = File.join(opts[:source_dir].to_s, 'references')
911
+ return unless Dir.exist?(ref_src)
912
+
913
+ ref_dest = File.join(opts[:dest_dir].to_s, 'references')
914
+ Dir.glob(File.join(ref_src, '**', '*')).each do |from|
915
+ next unless File.file?(from)
916
+
917
+ rel = from.delete_prefix("#{ref_src}/")
918
+ to = File.join(ref_dest, rel)
919
+ next if File.file?(to)
920
+
921
+ FileUtils.mkdir_p(File.dirname(to))
922
+ FileUtils.cp(from, to)
923
+ end
924
+ end
925
+
926
+ private_class_method def self.ingest_skill_md(opts = {})
927
+ skills = opts[:skills]
928
+ entry = opts[:entry].to_s
929
+ rel_dir = opts[:rel_dir].to_s
930
+ return skills unless skills.is_a?(Hash)
931
+ return skills if entry.empty? || rel_dir.empty?
932
+
933
+ key = rel_dir.to_sym
934
+ return skills if skills.key?(key)
935
+
936
+ dir = File.dirname(entry)
937
+ content = File.read(entry)
938
+ parsed = parse_skill_frontmatter(content: content)
939
+ fm = parsed[:frontmatter]
940
+ desc = (fm['description'] || fm[:description]).to_s.strip
941
+ desc = parsed[:body].to_s.lines.first.to_s.strip.sub(/^#+\s*/, '')[0, 200] if desc.empty?
942
+ scripts = Dir.glob(File.join(dir, 'scripts', '*.rb'))
943
+ fmt = module_skill_rel?(rel: rel_dir) ? :module : :agentskills
944
+ meta = {
945
+ type: scripts.any? ? :ruby : :instruction,
946
+ format: fmt,
947
+ path: entry,
948
+ dir: dir,
949
+ content: content,
950
+ description: desc,
951
+ frontmatter: fm,
952
+ references: parse_skill_references(content: content),
953
+ allowed_tools: Array(fm['allowed-tools'] || fm[:'allowed-tools'] || fm['allowed_tools'])
954
+ }
955
+ scripts.each do |rb|
956
+ require rb
957
+ rescue StandardError => e
958
+ meta[:loaded] = false
959
+ meta[:error] = e.message
960
+ end
961
+ meta[:loaded] = true unless meta.key?(:loaded) || scripts.empty?
962
+ skills[key] = meta
963
+ skills
912
964
  end
913
965
 
914
966
  # Supported Method Parameters::
@@ -919,7 +971,8 @@ module PWN
919
971
  # Loads skills into the PWN::Skills constant. Two on-disk shapes are
920
972
  # accepted so upgrades are seamless:
921
973
  #
922
- # agentskills.io → <root>/<name>/SKILL.md (preferred; written by write_skill)
974
+ # agentskills.io → <root>/**/SKILL.md (recursive; written by write_skill)
975
+ # module skills → <root>/pwn/**/SKILL.md
923
976
  # legacy flat → <root>/<name>.{md,txt,rb,skill,yml,yaml}
924
977
  #
925
978
  # Each entry: { type:, format:, path:, dir:, content:, description:,
@@ -931,38 +984,8 @@ module PWN
931
984
  skills = {}
932
985
  return skills unless pwn_skills_path && Dir.exist?(pwn_skills_path.to_s)
933
986
 
934
- # ── agentskills.io directory layout ───────────────────────────────
935
- Dir.glob(File.join(pwn_skills_path, '*', SKILL_ENTRY)).each do |entry|
936
- dir = File.dirname(entry)
937
- key = File.basename(dir).to_sym
938
- content = File.read(entry)
939
- parsed = parse_skill_frontmatter(content: content)
940
- fm = parsed[:frontmatter]
941
- desc = (fm['description'] || fm[:description]).to_s.strip
942
- desc = parsed[:body].to_s.lines.first.to_s.strip.sub(/^#+\s*/, '')[0, 200] if desc.empty?
943
- scripts = Dir.glob(File.join(dir, 'scripts', '*.rb'))
944
-
945
- meta = {
946
- type: scripts.any? ? :ruby : :instruction,
947
- format: :agentskills,
948
- path: entry,
949
- dir: dir,
950
- content: content,
951
- description: desc,
952
- frontmatter: fm,
953
- references: parse_skill_references(content: content),
954
- allowed_tools: Array(fm['allowed-tools'] || fm[:'allowed-tools'] || fm['allowed_tools'])
955
- }
956
-
957
- scripts.each do |rb|
958
- require rb
959
- rescue StandardError => e
960
- meta[:loaded] = false
961
- meta[:error] = e.message
962
- end
963
- meta[:loaded] = true unless meta.key?(:loaded) || scripts.empty?
964
-
965
- skills[key] = meta
987
+ each_skill_md(root: pwn_skills_path) do |entry, rel_dir|
988
+ ingest_skill_md(skills: skills, entry: entry, rel_dir: rel_dir)
966
989
  end
967
990
 
968
991
  # ── legacy flat files (backward-compat shim) ──────────────────────
@@ -998,30 +1021,6 @@ module PWN
998
1021
  end
999
1022
  end
1000
1023
 
1001
- # ── generated module skills: ~/.pwn/skills/pwn/**/SKILL.md ──────
1002
- Dir.glob(File.join(pwn_skills_path, 'pwn', '**', 'SKILL.md')).each do |skill_file|
1003
- next if skill_file.include?('/references/')
1004
-
1005
- rel = skill_file.delete_prefix("#{pwn_skills_path}/").sub(%r{/SKILL\.md\z}, '')
1006
- key = rel.to_sym
1007
- next if skills.key?(key)
1008
-
1009
- content = File.read(skill_file)
1010
- parsed = parse_skill_frontmatter(content: content)
1011
- desc = (parsed[:frontmatter]['description'] || parsed[:frontmatter][:description]).to_s.strip
1012
- desc = parsed[:body].to_s.lines.first.to_s.strip.sub(/^#+\s*/, '')[0, 200] if desc.empty?
1013
- skills[key] = {
1014
- type: :instruction,
1015
- format: :module,
1016
- path: skill_file,
1017
- dir: File.dirname(skill_file),
1018
- content: content,
1019
- description: desc,
1020
- frontmatter: parsed[:frontmatter],
1021
- references: parse_skill_references(content: content)
1022
- }
1023
- end
1024
-
1025
1024
  PWN.send(:remove_const, :Skills) if PWN.const_defined?(:Skills)
1026
1025
  PWN.const_set(:Skills, skills.freeze)
1027
1026
  skills
@@ -22,7 +22,7 @@ module PWN
22
22
 
23
23
  found.concat(parse_file(path: path, lib_root: lib))
24
24
  end
25
- found.uniq { |h| h[:source] }
25
+ found.uniq { |h| h[:source] }.each { |rec| enrich_live_methods!(rec: rec) }
26
26
  end
27
27
 
28
28
  public_class_method def self.relpath(opts = {})
@@ -132,6 +132,48 @@ module PWN
132
132
  { modules: mods.length, written: written, removed: removed, dest: dest }
133
133
  end
134
134
 
135
+ public_class_method def self.drift(opts = {})
136
+ dest = opts[:dest].to_s
137
+ dest = File.expand_path('../../etc/default_skills', __dir__) if dest.empty?
138
+ dest = File.dirname(dest) if File.basename(dest) == 'pwn'
139
+ lib = opts[:lib_root].to_s
140
+ lib = File.expand_path('..', LIB_ROOT) if lib.empty?
141
+ mods = enumerate(lib_root: lib)
142
+ missing = []
143
+ stale = []
144
+ keep = {}
145
+ mods.each do |rec|
146
+ path = skill_abs(dest: dest, module: rec)
147
+ dir = File.dirname(path)
148
+ keep[path] = true
149
+ if preserved_skill?(path: path)
150
+ Dir.glob(File.join(dir, 'references', '*.md')).each { |p| keep[p] = true }
151
+ next
152
+ end
153
+
154
+ if File.file?(path)
155
+ stale << path if File.read(path) != render(module: rec)
156
+ else
157
+ missing << path
158
+ end
159
+ Array(rec[:references]).each do |ref|
160
+ keep[File.join(dir, 'references', ref[:file])] = true
161
+ end
162
+ end
163
+ orphans = []
164
+ root = File.join(dest, 'pwn')
165
+ if Dir.exist?(root)
166
+ Dir.glob(File.join(root, '**', '*')).each do |path|
167
+ next unless File.file?(path)
168
+ next if File.basename(path).start_with?('.')
169
+ next if keep[path]
170
+
171
+ orphans << path
172
+ end
173
+ end
174
+ { missing: missing, stale: stale, orphans: orphans, modules: mods.length }
175
+ end
176
+
135
177
  public_class_method def self.install(opts = {})
136
178
  root = opts[:pwn_skills_path]
137
179
  root = PWN::Config.pwn_skills_path if root.to_s.empty? && defined?(PWN::Config)
@@ -333,6 +375,33 @@ module PWN
333
375
  .downcase
334
376
  end
335
377
 
378
+ private_class_method def self.enrich_live_methods!(opts = {})
379
+ rec = opts[:rec]
380
+ return rec unless rec.is_a?(Hash)
381
+
382
+ parsed = Array(rec[:methods]).map(&:to_s)
383
+ extra = live_class_methods(const: rec[:const]) - parsed
384
+ rec[:methods] = parsed + extra.sort
385
+ rec
386
+ end
387
+
388
+ private_class_method def self.live_class_methods(opts = {})
389
+ const = opts[:const].to_s
390
+ return [] if const.empty?
391
+
392
+ mod = const.split('::').inject(Object) do |cur, part|
393
+ return [] unless cur.is_a?(Module)
394
+ return [] unless cur.const_defined?(part)
395
+
396
+ cur.const_get(part)
397
+ end
398
+ return [] unless mod.is_a?(Module)
399
+
400
+ mod.singleton_methods(false).map(&:to_s)
401
+ rescue StandardError
402
+ []
403
+ end
404
+
336
405
  private_class_method def self.example_call(opts = {})
337
406
  const = opts[:const]
338
407
  methods = Array(opts[:methods]) - %w[help authors]
@@ -370,6 +439,7 @@ module PWN
370
439
  USAGE:
371
440
  #{self}.enumerate
372
441
  #{self}.refresh!
442
+ #{self}.drift
373
443
  #{self}.install(pwn_skills_path: '~/.pwn/skills')
374
444
  #{self}.authors
375
445
  USAGE
@@ -85,7 +85,7 @@ module PWN
85
85
  rescue RestClient::Forbidden, RestClient::Unauthorized => e
86
86
  @@logger.error("GitHub #{e.class}: #{e.response&.body}")
87
87
  raise e
88
- rescue RestClient::BadRequest, RestClient::NotFound, StandardError => e
88
+ rescue StandardError => e
89
89
  raise e
90
90
  end
91
91
 
@@ -23,12 +23,8 @@ module PWN
23
23
  PWN::Banner
24
24
  ].freeze
25
25
  DEFAULT_TRACE_PREFIXES = %w[
26
- PWN::AI
27
- PWN::Memory
28
- PWN::Sessions
29
- PWN::Config
30
- PWN::Cron
31
- PWN::Plugins
26
+ PWN::AI::Agent::Loop
27
+ PWN::AI::Agent::Dispatch
32
28
  ].freeze
33
29
  SECRET_KEY_RX = /password|passwd|secret|token|api[_-]?key|authorization|bearer|cookie|session[_-]?id|private[_-]?key|decryptor|credential|ssh[_-]?key|client[_-]?secret|refresh[_-]?token|access[_-]?token|id[_-]?token|vault|csrf/i
34
30
  SECRET_VALUE_RX = %r{
@@ -150,10 +146,13 @@ module PWN
150
146
  # /tmp/pwn-ai-DEBUG-<session_id>-RN.log via next_request_log!.
151
147
  # opts[:path] is a test override that writes to one file.
152
148
  public_class_method def self.start_debug(opts = {})
149
+ want_trace = opts[:trace] == true
153
150
  if debug_enabled? && opts[:path].to_s.empty?
154
151
  @debug_tee = opts[:tee] if opts.key?(:tee)
155
152
  sid = sanitize_debug_session_id(session_id: opts[:session_id])
156
153
  @debug_session_id = sid unless sid.empty?
154
+ @debug_step_io = opts[:step_io] if opts.key?(:step_io)
155
+ apply_trace!(trace: want_trace, prefixes: opts[:prefixes]) if want_trace
157
156
  return @debug_path
158
157
  end
159
158
 
@@ -165,18 +164,23 @@ module PWN
165
164
  @debug_session_id = sanitize_debug_session_id(session_id: opts[:session_id])
166
165
  @debug_req_n = 0
167
166
  @debug_request_open = false
167
+ @debug_step_io = opts[:step_io]
168
168
  if path.empty?
169
169
  close_debug_file!
170
170
  @debug_path = nil
171
171
  else
172
172
  open_debug_file!(path: path)
173
173
  end
174
- start_trace!(prefixes: opts[:prefixes]) if opts[:trace] == true
174
+ apply_trace!(trace: want_trace, prefixes: opts[:prefixes])
175
175
  install_stderr_tee!
176
176
  progress(msg: 'debug session start', which_self: self) if @debug_file
177
177
  @debug_path
178
178
  end
179
179
 
180
+ public_class_method def self.trace_enabled?
181
+ @debug_trace == true
182
+ end
183
+
180
184
  public_class_method def self.next_request_log!(opts = {})
181
185
  return unless debug_enabled?
182
186
  return @debug_path if @debug_request_open && opts[:force] != true
@@ -226,6 +230,8 @@ module PWN
226
230
  @debug_tee = nil
227
231
  @debug_tui_quiet = false
228
232
  @debug_enabled = false
233
+ @debug_step = false
234
+ @debug_step_io = nil
229
235
  remove_stderr_tee!
230
236
  path
231
237
  end
@@ -574,6 +580,16 @@ module PWN
574
580
  opts[:key].to_s.match?(SECRET_KEY_RX)
575
581
  end
576
582
 
583
+ private_class_method def self.opaque_secret?(opts = {})
584
+ return false unless opts[:key].to_s == 'value'
585
+
586
+ val = opts[:value]
587
+ return false unless val.is_a?(String)
588
+ return false if val.length < 24
589
+
590
+ val.match?(%r{\A[A-Za-z0-9\-_/=+.]+\z})
591
+ end
592
+
577
593
  private_class_method def self.sanitize_debug_text(opts = {})
578
594
  text = opts[:text].to_s
579
595
  text = text.gsub(SECRET_VALUE_RX, '[REDACTED]')
@@ -584,6 +600,7 @@ module PWN
584
600
  val = opts[:value]
585
601
  key = opts[:key]
586
602
  return '[REDACTED]' if secret_key?(key: key)
603
+ return '[REDACTED]' if opaque_secret?(key: key, value: val)
587
604
  return format_debug_hash(hash: val) if val.is_a?(Hash)
588
605
  return '[REDACTED]' if val.is_a?(String) && val.match?(SECRET_VALUE_RX)
589
606
 
@@ -667,6 +684,7 @@ module PWN
667
684
  nil
668
685
  end
669
686
  @debug_tp.enable
687
+ @debug_trace = true
670
688
  @debug_tp
671
689
  end
672
690
 
@@ -675,6 +693,50 @@ module PWN
675
693
 
676
694
  @debug_tp&.disable
677
695
  @debug_tp = nil
696
+ @debug_trace = false
697
+ @debug_step = false
698
+ end
699
+
700
+ public_class_method def self.wait_trace_step!(opts = {})
701
+ return unless debug_enabled?
702
+ return unless @debug_step
703
+ return if opts[:nested]
704
+
705
+ io = @debug_step_io || $stdin
706
+ return unless io.respond_to?(:gets)
707
+ return if io.equal?($stdin) && !$stdin.tty?
708
+
709
+ label = opts[:label].to_s
710
+ label = 'loop' if label.empty?
711
+ progress(msg: "trace step #{label} — Press ENTER to Continue...", which_self: self, tee: nil)
712
+ prompt_trace_enter!
713
+ io.gets
714
+ true
715
+ rescue StandardError
716
+ nil
717
+ end
718
+
719
+ private_class_method def self.prompt_trace_enter!(opts = {})
720
+ return if opts[:skip]
721
+
722
+ tee = @debug_tee
723
+ return unless tee.respond_to?(:puts)
724
+
725
+ tee.print("\e[31mPress ENTER to Continue...\e[0m")
726
+ tee.flush if tee.respond_to?(:flush)
727
+ $stdout.flush if $stdout.respond_to?(:flush)
728
+ rescue StandardError
729
+ nil
730
+ end
731
+
732
+ private_class_method def self.apply_trace!(opts = {})
733
+ if opts[:trace]
734
+ @debug_step = true
735
+ start_trace!(prefixes: opts[:prefixes])
736
+ @debug_trace = true
737
+ else
738
+ @debug_step = false
739
+ end
678
740
  end
679
741
 
680
742
  # Author(s):: 0day Inc. <support@0dayinc.com>
@@ -703,7 +765,8 @@ module PWN
703
765
  #{self}.progress(msg: 'stage', which_self: self)
704
766
  #{self}.finish_request_log!(iter: 1, tools_called: 0, engine_s: 0.2, final_chars: 12)
705
767
  #{self}.stop_debug
706
- # start_debug(trace: true) enables per-call TracePoint (opt-in)
768
+ # start_debug(trace: true) enables TracePoint + ENTER after each Loop step
769
+ # (toggle-trace). Stored on Pry.config.pwn_ai_trace, not Env.
707
770
  "
708
771
  end
709
772
  end