pwn 0.5.680 → 0.5.683

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/documentation/AI-Integration.md +1 -1
  4. data/documentation/Agent-Tool-Registry.md +11 -7
  5. data/documentation/Configuration.md +6 -7
  6. data/documentation/How-PWN-Works.md +2 -2
  7. data/documentation/Reinforcement-Learning.md +1 -1
  8. data/documentation/diagrams/agent-tool-registry.svg +1 -1
  9. data/documentation/diagrams/dot/agent-tool-registry.dot +1 -1
  10. data/documentation/diagrams/dot/task-summarizer.dot +3 -3
  11. data/documentation/pwn-ai-Agent.md +16 -35
  12. data/lib/pwn/ai/agent/loop.rb +265 -192
  13. data/lib/pwn/ai/agent/mistakes.rb +17 -0
  14. data/lib/pwn/ai/agent/policy.rb +53 -4
  15. data/lib/pwn/ai/agent/prompt_builder.rb +46 -19
  16. data/lib/pwn/ai/agent/registry.rb +18 -13
  17. data/lib/pwn/ai/agent/task_summarizer.rb +374 -503
  18. data/lib/pwn/ai/agent/tools/sessions.rb +32 -0
  19. data/lib/pwn/ai/agent/tools/skills.rb +56 -0
  20. data/lib/pwn/ai/anthropic.rb +0 -1
  21. data/lib/pwn/ai/gemini.rb +0 -1
  22. data/lib/pwn/ai/grok.rb +0 -1
  23. data/lib/pwn/ai/ollama.rb +0 -1
  24. data/lib/pwn/ai/open_ai.rb +0 -1
  25. data/lib/pwn/ai/open_web_ui.rb +0 -1
  26. data/lib/pwn/config.rb +1 -1
  27. data/lib/pwn/plugins/repl.rb +37 -0
  28. data/lib/pwn/plugins/tty_spinner.rb +55 -6
  29. data/lib/pwn/sessions.rb +82 -0
  30. data/lib/pwn/version.rb +1 -1
  31. data/spec/integration/prompt_builder_spec.rb +6 -4
  32. data/spec/lib/pwn/ai/agent/loop_spec.rb +314 -34
  33. data/spec/lib/pwn/ai/agent/mistakes_spec.rb +14 -0
  34. data/spec/lib/pwn/ai/agent/policy_spec.rb +52 -1
  35. data/spec/lib/pwn/ai/agent/prompt_builder_spec.rb +8 -9
  36. data/spec/lib/pwn/ai/agent/registry_spec.rb +30 -3
  37. data/spec/lib/pwn/ai/agent/signal_hygiene_spec.rb +4 -5
  38. data/spec/lib/pwn/ai/agent/task_summarizer_spec.rb +321 -90
  39. data/spec/lib/pwn/ai/agent/tools/sessions_spec.rb +5 -0
  40. data/spec/lib/pwn/ai/agent/tools/skills_spec.rb +16 -0
  41. data/spec/lib/pwn/ai/red_team/test_case_engine_spec.rb +20 -0
  42. data/spec/lib/pwn/plugins/repl_spec.rb +9 -0
  43. data/spec/lib/pwn/plugins/tty_spinner_spec.rb +30 -0
  44. data/spec/lib/pwn/sessions_spec.rb +29 -0
  45. data/spec/spec_helper.rb +6 -0
  46. data/third_party/pwn_rdoc.jsonl +30 -9
  47. metadata +1 -1
@@ -500,6 +500,7 @@ module PWN
500
500
  limit = opts[:limit] || 6
501
501
  request = opts[:request].to_s
502
502
  open_rows = top(limit: limit * 3, unresolved_only: true)
503
+ open_rows = open_rows.reject { |m| budget_scar?(mistake: m) } unless request.match?(/budget|iterat|exhaust|\bagent.?loop\b/i)
503
504
  # 2.6 — request-conditioned rank (sim × recency × count), same idea
504
505
  # as exemplars_for. Stops injecting loudest scar (reward_signal ×13)
505
506
  # on every unrelated turn.
@@ -534,6 +535,21 @@ module PWN
534
535
  "#{out}\n"
535
536
  end
536
537
 
538
+ private_class_method def self.budget_scar?(opts = {})
539
+ m = opts[:mistake] || opts[:m] || opts
540
+ return false unless m.is_a?(Hash)
541
+
542
+ shape = m[:shape].to_s
543
+ err = m[:error].to_s.downcase
544
+ tool = m[:tool].to_s
545
+ return true if %w[budget_exhausted budget_thrash].include?(shape)
546
+ return true if err.include?('budget exhausted') || err.include?('iteration budget') || err.include?('budget thrash')
547
+
548
+ %w[agent_loop assistant_answer].include?(tool) && err.include?('budget')
549
+ rescue StandardError
550
+ false
551
+ end
552
+
537
553
  private_class_method def self.rank_for_request(opts = {})
538
554
  rows = Array(opts[:rows])
539
555
  limit = opts[:limit] || 6
@@ -553,6 +569,7 @@ module PWN
553
569
  # downrank reward_signal / parked unless the request is about rewards
554
570
  penalty = 1.0
555
571
  penalty *= 0.05 if m[:tool].to_s == 'reward_signal' && !req.match?(/reward|judge|sentinel|proxy/)
572
+ penalty *= 0.05 if budget_scar?(mistake: m) && !req.match?(/budget|iterat|exhaust|loop/)
556
573
  penalty *= 0.3 if m[:parked] || m[:needs_code_change]
557
574
  score = ((sim * 2.0) + (decay * 0.5) + (Math.log2(m[:count].to_i + 1) * 0.3)) * penalty
558
575
  # always allow some mass for top-count scars when sim=0 but keep penalty
@@ -36,6 +36,9 @@ module PWN
36
36
  EPSILON = 0.08
37
37
  STEP_OK = 0.05
38
38
  STEP_FAIL = -0.20
39
+ STEP_TASK = 0.12
40
+ STEP_CLOSED = 0.20
41
+ STEP_GRIND = -0.08
39
42
  MAX_TRAJ = 2_000
40
43
  GOLD_MIN = 0.6
41
44
  VISITS_MIN = 2
@@ -147,6 +150,8 @@ module PWN
147
150
  started_at: Time.now.utc.iso8601,
148
151
  state: s0,
149
152
  last_action: 'start',
153
+ plan_idx: ts_idx(ts_state: opts[:ts_state]),
154
+ plan_open: ts_open?(ts_state: opts[:ts_state]),
150
155
  fails: 0,
151
156
  steps: []
152
157
  }
@@ -197,6 +202,9 @@ module PWN
197
202
  else
198
203
  ok ? STEP_OK : STEP_FAIL
199
204
  end
205
+ reward = (reward + english_step_bonus(ep: ep, ts_state: opts[:ts_state], ok: ok, action: action)).round(4)
206
+ ep[:plan_idx] = ts_idx(ts_state: opts[:ts_state])
207
+ ep[:plan_open] = ts_open?(ts_state: opts[:ts_state])
200
208
  s = ep[:state]
201
209
  task = active_task_text(ts_state: opts[:ts_state], request: ep[:request])
202
210
  s2 = state(
@@ -572,7 +580,7 @@ module PWN
572
580
  end
573
581
 
574
582
  ev = evaluate(limit: opts[:limit] || 40)
575
- fallback = %w[memory_recall sessions_view pwn_eval shell mistakes_record mistakes_resolve learning_note_outcome memory_remember]
583
+ fallback = PWN::AI::Agent::Registry::DEFAULT_PREFERENCE
576
584
  pref = begin
577
585
  PWN::AI::Agent::Registry.preference_order
578
586
  rescue StandardError
@@ -724,6 +732,36 @@ module PWN
724
732
  opts[:request].to_s
725
733
  end
726
734
 
735
+ private_class_method def self.ts_idx(opts = {})
736
+ ts = opts[:ts_state]
737
+ ts.is_a?(Hash) ? ts[:plan_idx].to_i : 0
738
+ end
739
+
740
+ private_class_method def self.ts_open?(opts = {})
741
+ ts = opts[:ts_state]
742
+ return true unless ts.is_a?(Hash)
743
+ return true unless defined?(TaskSummarizer) && TaskSummarizer.respond_to?(:plan_open?)
744
+
745
+ TaskSummarizer.plan_open?(state: ts)
746
+ rescue StandardError
747
+ true
748
+ end
749
+
750
+ private_class_method def self.english_step_bonus(opts = {})
751
+ ep = opts[:ep]
752
+ return 0.0 unless ep.is_a?(Hash)
753
+
754
+ bonus = 0.0
755
+ new_idx = ts_idx(ts_state: opts[:ts_state])
756
+ new_open = ts_open?(ts_state: opts[:ts_state])
757
+ bonus += STEP_TASK if !ep[:plan_idx].nil? && new_idx > ep[:plan_idx].to_i
758
+ bonus += STEP_CLOSED if ep[:plan_open] && new_open == false
759
+ bonus += STEP_GRIND if new_open == false && opts[:ok] && opts[:action].to_s != 'final'
760
+ bonus
761
+ rescue StandardError
762
+ 0.0
763
+ end
764
+
727
765
  private_class_method def self.terminal_reward(opts = {})
728
766
  return ((2.0 * opts[:score].to_f) - 1.0).clamp(-1.0, 1.0) unless opts[:score].nil?
729
767
 
@@ -800,9 +838,20 @@ module PWN
800
838
  plan = Array(ts[:plan]).map { |t| t.to_s.strip }.reject(&:empty?)
801
839
  return 'n' if plan.empty?
802
840
 
803
- idx = ts[:plan_idx].to_i.clamp(0, plan.length)
804
- frac = idx.to_f / plan.length
805
- return 'h' if frac >= 0.75 || idx >= (plan.length - 1)
841
+ open = if defined?(TaskSummarizer) && TaskSummarizer.respond_to?(:plan_open?)
842
+ TaskSummarizer.plan_open?(state: ts)
843
+ else
844
+ true
845
+ end
846
+ return 'h' unless open
847
+
848
+ left = if defined?(TaskSummarizer) && TaskSummarizer.respond_to?(:unfinished_tasks)
849
+ Array(TaskSummarizer.unfinished_tasks(state: ts)).length
850
+ else
851
+ plan.length
852
+ end
853
+ done = plan.length - left
854
+ frac = done.to_f / plan.length
806
855
  return 'm' if frac >= 0.3
807
856
 
808
857
  'l'
@@ -36,14 +36,42 @@ module PWN
36
36
  session_id = opts[:session_id]
37
37
  request = opts[:request]
38
38
  engine = active_engine
39
+ # thin: greeting/statement/howto/recall — base + ENV + optional recent turns.
40
+ # Full MEMORY/METRICS/MISTAKES/EXTRO only for act/recon (default).
41
+ thin = opts[:thin] == true || opts[:mode].to_s == 'thin'
39
42
  b = budget
40
43
  base = (PWN::Env.dig(:ai, engine, :system_role_content) if defined?(PWN::Env)) || 'You are a world-class introspective offensive cyber security and research engineer. You specialize in discovering zero day vulnerabilities focused on responsible disclosure prior to threat actors discovering and exploiting. You are self-aware of your harness, pwn which begins with the ruby namespace `PWN` operating inside the pwn REPL. For every request you first begin by determining if PWN has a module capable of satisfying the request.'
41
44
 
45
+ if thin
46
+ recent = recent_turns_block(session_id: session_id, request: request, limit: [b[:recent_turns].to_i, 2].min)
47
+ return <<~PROMPT
48
+ #{base}
49
+
50
+ ENVIRONMENT
51
+ host : #{host_line}
52
+ cwd : #{Dir.pwd}
53
+ ruby : #{RUBY_VERSION}
54
+ pwn : #{pwn_version}
55
+ session_id : #{session_id || '(none)'}
56
+
57
+ #{recent}TOOL USE
58
+ No tools on this turn unless a single factual lookup is already in
59
+ context. Answer concisely in plain US English. Do not plan multi-step
60
+ work, invent task traces, or run live recon.
61
+ PROMPT
62
+ end
63
+
42
64
  # Heredoc (not a "..." literal): an unescaped "..." inside a
43
65
  # double-quoted string is parsed as Range (begin..."...end).
44
- # Skills sit in the STATIC prefix (Hermes index) so prompt-cache
45
- # breakpoints can pin the persona + SKILLS list; MEMORY/LEARNING
46
- # remain in the dynamic tail.
66
+ # Mid-turn: current session + memory + skills + learning + known-fix,
67
+ # then tools (memory_recall / session_recall / skills_recall / pwn_eval / shell).
68
+ # Expand METRICS/POLICY/EXTRO when stuck or expand_harness.
69
+ expand = opts[:expand_harness] == true || opts[:stuck] == true
70
+ harness = if expand
71
+ "#{skills_block}#{recent_turns_block(session_id: session_id, request: request, limit: b[:recent_turns])}#{memory_block(limit: b[:memory], request: request)}#{learning_block(limit: b[:learning])}#{mistakes_block(limit: b[:mistakes], request: request)}#{metrics_block(limit: b[:metrics], engine: engine)}#{policy_block if b[:policy].to_i.positive?}#{extrospection_block if b[:extro]}"
72
+ else
73
+ "#{recent_turns_block(session_id: session_id, request: request, limit: b[:recent_turns])}#{memory_block(limit: b[:memory], request: request)}#{skills_block}#{learning_block(limit: b[:learning])}#{mistakes_block(limit: b[:mistakes], request: request)}"
74
+ end
47
75
  <<~PROMPT
48
76
  #{base}
49
77
 
@@ -54,7 +82,7 @@ module PWN
54
82
  pwn : #{pwn_version}
55
83
  session_id : #{session_id || '(none)'}
56
84
 
57
- #{skills_block}#{memory_block(limit: b[:memory], request: request)}#{recent_turns_block(session_id: session_id, request: request, limit: b[:recent_turns])}#{learning_block(limit: b[:learning])}#{mistakes_block(limit: b[:mistakes], request: request)}#{metrics_block(limit: b[:metrics], engine: engine)}#{policy_block if b[:policy].to_i.positive?}#{extrospection_block if b[:extro]}TOOL USE
85
+ #{harness}TOOL USE
58
86
  Use the provided function tools to act on the host via NATIVE
59
87
  tool_calls / function calling — never print tool invocations as
60
88
  plain text (e.g. do NOT write shell(command="...") as your answer).
@@ -62,12 +90,16 @@ module PWN
62
90
  "I will run…", "one more thing…") — that is treated as an incomplete
63
91
  reply. Emit a real tool_call instead, or a complete final answer
64
92
  with evidence. A reply with no tool_calls is your FINAL answer to the user.
65
- Prefer `pwn_eval` for anything in the PWN:: namespace and `shell`
66
- for OS commands. Save durable facts with `memory_remember`.
93
+ Prefer this order: use RECENT TURNS (current session already in
94
+ context), then `memory_recall`, then `session_recall`, then
95
+ `skills_recall`, then `pwn_eval` for PWN:: work, then `shell` for OS
96
+ commands. Save durable facts with `memory_remember`.
97
+ TransparentBrowser: open once, reuse browser_obj, close once.
67
98
 
68
99
  AUTONOMY
69
100
  Multi-step goals must be finished in one Loop.run. Keep calling
70
- tools until the request is done or truly blocked. Do NOT stop to
101
+ CORE_TOOLS until the original request is done or truly blocked.
102
+ English tasks are an advisory compass, not a gate. Do NOT stop to
71
103
  ask the user to confirm the next step, approve a partial plan, or
72
104
  green-light the obvious continuation. Only ask when a credential,
73
105
  irreversible destructive action, or missing external decision is
@@ -75,15 +107,10 @@ module PWN
75
107
  goal are incorrect behavior.
76
108
 
77
109
  INTENT AND SCOPE
78
- First classify every user request as one of:
79
- - general statement — observation or FYI; no multi-step task plan
80
- - question — answer concisely; no multi-step task breakdown
81
- - autonomous goal — MUST decompose into ordered tangible work units
82
- (each unit may use one or more tools) and finish them in this run
83
- Match effort to that kind. Pure how-to / syntax / usage questions
84
- get a concise explanation with example commands only — no tool calls,
85
- no multi-step recon plan, no live probes, no rubocop/rake/docs side
86
- quests, and no invented planner or verification monologue.
110
+ Every user request is an autonomous goal. Finish it in this run
111
+ with CORE_TOOLS. English tasks are an advisory compass only.
112
+ Pure how-to / syntax / usage questions get a concise explanation
113
+ with example commands only — no invented planner monologue.
87
114
  Pure prior-turn recall ("what did I just say?") is answered from the
88
115
  RECENT TURNS block or one memory_recall — never a multi-tool plan.
89
116
  Pure greetings / light smalltalk short-circuit to a fixed ack — never
@@ -114,13 +141,13 @@ module PWN
114
141
  metrics: (b[:metrics] || (local ? 3 : 8)).to_i,
115
142
  mistakes: (b[:mistakes] || (local ? 3 : 6)).to_i,
116
143
  learning: (b[:learning] || (local ? 2 : 5)).to_i,
117
- # Always inject last user/assistant pair(s); local keeps it tiny.
118
- recent_turns: (b[:recent_turns] || (local ? 1 : 2)).to_i,
144
+ # Always inject prior user/assistant pairs from this session.
145
+ recent_turns: (b[:recent_turns] || (local ? 3 : 6)).to_i,
119
146
  policy: (b[:policy] || 1).to_i,
120
147
  extro: b[:extro].nil? ? !local : b[:extro]
121
148
  }
122
149
  rescue StandardError
123
- { memory: 25, metrics: 8, mistakes: 6, learning: 5, recent_turns: 2, policy: 1, extro: true }
150
+ { memory: 25, metrics: 8, mistakes: 6, learning: 5, recent_turns: 6, policy: 1, extro: true }
124
151
  end
125
152
 
126
153
  private_class_method def self.active_engine
@@ -40,14 +40,14 @@ module PWN
40
40
  keyword_init: true
41
41
  )
42
42
 
43
- CORE_TOOLS = %w[shell pwn_eval memory_remember memory_recall
44
- mistakes_record mistakes_resolve learning_note_outcome].freeze
43
+ CORE_TOOLS = %w[memory_recall session_recall skills_recall pwn_eval shell
44
+ mistakes_record mistakes_resolve learning_note_outcome memory_remember].freeze
45
45
 
46
- # Operator-tunable tool order. Prefer these names when keyword-fit
47
- # (or other rank features) tie. Overridable via opts[:order],
46
+ # Schema order = CORE_TOOLS. Current session is injected (RECENT TURNS).
47
+ # Then memory / prior sessions / skills, then pwn_eval before shell.
48
48
  # opts[:preference], or PWN::Env[:ai][:agent][:tool_preference].
49
49
  # Explicit nil/empty order disables preference (no Env/DEFAULT fallback).
50
- DEFAULT_PREFERENCE = %w[memory_recall sessions_view pwn_eval shell mistakes_record mistakes_resolve learning_note_outcome memory_remember].freeze
50
+ DEFAULT_PREFERENCE = CORE_TOOLS
51
51
 
52
52
  @entries = {}
53
53
  @discovered = false
@@ -119,8 +119,12 @@ module PWN
119
119
  pref_fwd = {}
120
120
  pref_fwd[:order] = opts[:order] if opts.key?(:order)
121
121
  pref_fwd[:preference] = opts[:preference] if opts.key?(:preference)
122
+ pref_fwd[:kind] = opts[:kind] if opts.key?(:kind)
123
+ pref_fwd[:intent] = opts[:intent] if opts.key?(:intent)
122
124
 
123
- if opts[:relevance] && router_enabled?
125
+ if opts[:core_only]
126
+ pool = pool.select { |e| CORE_TOOLS.include?(e.name) }
127
+ elsif opts[:relevance] && router_enabled?
124
128
  keep = rank({ query: opts[:relevance], entries: pool }.merge(pref_fwd)).first(opts[:top_k] || 10).map(&:name)
125
129
  names = (CORE_TOOLS + keep).uniq
126
130
  pool = pool.select { |e| names.include?(e.name) }
@@ -147,8 +151,9 @@ module PWN
147
151
 
148
152
  raw = nil
149
153
  raw = PWN::Env.dig(:ai, :agent, :tool_preference) if defined?(PWN::Env) && PWN::Env.is_a?(Hash)
150
- raw = DEFAULT_PREFERENCE if raw.nil? || (raw.respond_to?(:empty?) && raw.empty?)
151
- Array(raw).map(&:to_s).reject(&:empty?)
154
+ return Array(raw).map(&:to_s).reject(&:empty?) unless raw.nil? || (raw.respond_to?(:empty?) && raw.empty?)
155
+
156
+ DEFAULT_PREFERENCE.dup
152
157
  rescue StandardError
153
158
  DEFAULT_PREFERENCE.dup
154
159
  end
@@ -315,13 +320,13 @@ module PWN
315
320
  return false unless defined?(PWN::Env) && PWN::Env.is_a?(Hash)
316
321
 
317
322
  v = PWN::Env.dig(:ai, :agent, :tool_router)
318
- # nil = auto: on for ollama (largest single local-model win — ~11k→~3k
319
- # schema tokens/turn); off for frontier unless explicitly enabled.
320
- return v ? true : false unless v.nil?
323
+ # nil = auto ON for every engine: ~85 tools / ~50KB schemas bloat every
324
+ # turn (esp. Grok). Opt out with tool_router: false.
325
+ return !!v unless v.nil?
321
326
 
322
- %i[ollama openwebui].include?(PWN::Env.dig(:ai, :active).to_s.downcase.to_sym)
327
+ true
323
328
  rescue StandardError
324
- false
329
+ true
325
330
  end
326
331
 
327
332
  private_class_method def self.metrics_rates