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.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/documentation/AI-Integration.md +1 -1
- data/documentation/Agent-Tool-Registry.md +11 -7
- data/documentation/Configuration.md +6 -7
- data/documentation/How-PWN-Works.md +2 -2
- data/documentation/Reinforcement-Learning.md +1 -1
- data/documentation/diagrams/agent-tool-registry.svg +1 -1
- data/documentation/diagrams/dot/agent-tool-registry.dot +1 -1
- data/documentation/diagrams/dot/task-summarizer.dot +3 -3
- data/documentation/pwn-ai-Agent.md +16 -35
- data/lib/pwn/ai/agent/loop.rb +265 -192
- data/lib/pwn/ai/agent/mistakes.rb +17 -0
- data/lib/pwn/ai/agent/policy.rb +53 -4
- data/lib/pwn/ai/agent/prompt_builder.rb +46 -19
- data/lib/pwn/ai/agent/registry.rb +18 -13
- data/lib/pwn/ai/agent/task_summarizer.rb +374 -503
- data/lib/pwn/ai/agent/tools/sessions.rb +32 -0
- data/lib/pwn/ai/agent/tools/skills.rb +56 -0
- data/lib/pwn/ai/anthropic.rb +0 -1
- data/lib/pwn/ai/gemini.rb +0 -1
- data/lib/pwn/ai/grok.rb +0 -1
- data/lib/pwn/ai/ollama.rb +0 -1
- data/lib/pwn/ai/open_ai.rb +0 -1
- data/lib/pwn/ai/open_web_ui.rb +0 -1
- data/lib/pwn/config.rb +1 -1
- data/lib/pwn/plugins/repl.rb +37 -0
- data/lib/pwn/plugins/tty_spinner.rb +55 -6
- data/lib/pwn/sessions.rb +82 -0
- data/lib/pwn/version.rb +1 -1
- data/spec/integration/prompt_builder_spec.rb +6 -4
- data/spec/lib/pwn/ai/agent/loop_spec.rb +314 -34
- data/spec/lib/pwn/ai/agent/mistakes_spec.rb +14 -0
- data/spec/lib/pwn/ai/agent/policy_spec.rb +52 -1
- data/spec/lib/pwn/ai/agent/prompt_builder_spec.rb +8 -9
- data/spec/lib/pwn/ai/agent/registry_spec.rb +30 -3
- data/spec/lib/pwn/ai/agent/signal_hygiene_spec.rb +4 -5
- data/spec/lib/pwn/ai/agent/task_summarizer_spec.rb +321 -90
- data/spec/lib/pwn/ai/agent/tools/sessions_spec.rb +5 -0
- data/spec/lib/pwn/ai/agent/tools/skills_spec.rb +16 -0
- data/spec/lib/pwn/ai/red_team/test_case_engine_spec.rb +20 -0
- data/spec/lib/pwn/plugins/repl_spec.rb +9 -0
- data/spec/lib/pwn/plugins/tty_spinner_spec.rb +30 -0
- data/spec/lib/pwn/sessions_spec.rb +29 -0
- data/spec/spec_helper.rb +6 -0
- data/third_party/pwn_rdoc.jsonl +30 -9
- 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
|
data/lib/pwn/ai/agent/policy.rb
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
804
|
-
|
|
805
|
-
|
|
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
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
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
|
-
#{
|
|
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
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
118
|
-
recent_turns: (b[:recent_turns] || (local ?
|
|
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:
|
|
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[
|
|
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
|
-
#
|
|
47
|
-
#
|
|
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 =
|
|
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[:
|
|
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
|
|
151
|
-
|
|
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
|
|
319
|
-
#
|
|
320
|
-
return v
|
|
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
|
-
|
|
327
|
+
true
|
|
323
328
|
rescue StandardError
|
|
324
|
-
|
|
329
|
+
true
|
|
325
330
|
end
|
|
326
331
|
|
|
327
332
|
private_class_method def self.metrics_rates
|