pwn 0.5.760 → 0.5.762
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/README.md +5 -5
- data/documentation/Swarm.md +113 -0
- data/documentation/pwn-ai-Agent.md +45 -11
- data/etc/default_skills/pwn/ai/agent/solve/SKILL.md +46 -0
- data/etc/default_skills/pwn/ai/agent/swarm/SKILL.md +1 -0
- data/etc/default_skills/pwn/banner/SKILL.md +1 -1
- data/lib/pwn/ai/agent/dispatch.rb +3 -0
- data/lib/pwn/ai/agent/loop.rb +18 -4
- data/lib/pwn/ai/agent/solve.rb +342 -0
- data/lib/pwn/ai/agent/swarm.rb +41 -1
- data/lib/pwn/ai/agent.rb +1 -0
- data/lib/pwn/ai/anthropic.rb +1 -0
- data/lib/pwn/ai/grok.rb +1 -0
- data/lib/pwn/ai/open_ai.rb +1 -0
- data/lib/pwn/banner.rb +593 -53
- data/lib/pwn/plugins/repl/ai.rb +6 -1
- data/lib/pwn/plugins/repl/ai_console.rb +81 -18
- data/lib/pwn/plugins/repl/ai_console_commands.rb +5 -4
- data/lib/pwn/plugins/repl/ai_swarm.rb +44 -11
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/ai/agent/loop_spec.rb +2 -1
- data/spec/lib/pwn/ai/agent/solve_spec.rb +290 -0
- data/spec/lib/pwn/banner_spec.rb +428 -20
- data/spec/lib/pwn/plugins/repl/ai_console_commands_spec.rb +37 -1
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +218 -27
- data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +67 -7
- data/third_party/pwn_rdoc.jsonl +39 -2
- metadata +4 -1
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'pry'
|
|
5
|
+
|
|
6
|
+
RSpec.describe 'Swarm solve' do
|
|
7
|
+
include_context 'pwn tmp sandbox'
|
|
8
|
+
|
|
9
|
+
before do
|
|
10
|
+
@workspace = File.join(@tmp, 'work')
|
|
11
|
+
FileUtils.mkdir_p(@workspace)
|
|
12
|
+
allow(PWN::AI::Agent::Swarm).to receive(:build_persona_prompt).and_return('fixture')
|
|
13
|
+
allow(PWN::AI::Agent::Swarm).to receive(:child_inbox).and_return({})
|
|
14
|
+
allow(PWN::AI::Agent::Swarm).to receive(:child_honesty).and_return({})
|
|
15
|
+
allow(PWN::AI::Agent::Swarm).to receive(:bus_append).and_return({})
|
|
16
|
+
@turns = []
|
|
17
|
+
@bad = false
|
|
18
|
+
@reject = false
|
|
19
|
+
@repair = false
|
|
20
|
+
allow(PWN::AI::Agent::Loop).to receive(:run) do |opts|
|
|
21
|
+
handoff = JSON.parse(opts[:system_role_content].split("SOLVE HANDOFF\n").last, symbolize_names: true)
|
|
22
|
+
@turns << [opts, handoff]
|
|
23
|
+
revision = handoff[:revision]
|
|
24
|
+
answer = case handoff[:phase]
|
|
25
|
+
when 'assess' then { requirements: ['The file contains the requested greeting'], risks: ['Wrong bytes'] }
|
|
26
|
+
when 'build'
|
|
27
|
+
File.write(File.join(@workspace, 'greeting.txt'), @bad ? 'wrong' : 'hello')
|
|
28
|
+
{ artifacts: ['greeting.txt'], summary: 'candidate' }
|
|
29
|
+
when 'critique' then { revision: revision, objections: @reject ? ['Wrong output'] : [] }
|
|
30
|
+
when 'verify'
|
|
31
|
+
{ revision: revision, checks: [{ requirements: handoff[:requirements].keys, command: 'cat greeting.txt', stdout: 'hello' }] }
|
|
32
|
+
when 'integrate'
|
|
33
|
+
@bad = false if @repair
|
|
34
|
+
{ revision: revision, decision: handoff[:evidence].all? { |row| row[:passed] } ? 'accept' : 'repair', repair: 'Write hello', summary: 'Greeting delivered' }
|
|
35
|
+
end
|
|
36
|
+
@hook&.call(handoff, answer)
|
|
37
|
+
JSON.generate(answer)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'executes the real nested Loop with a deterministic provider and host tool' do
|
|
42
|
+
allow(PWN::AI::Agent::Loop).to receive(:run).and_call_original
|
|
43
|
+
allow(PWN::AI::Agent::TaskSummarizer).to receive(:enabled?).and_return(false)
|
|
44
|
+
PWN::Env[:ai][:active] = :openai
|
|
45
|
+
PWN::Env[:ai][:openai] = { key: 'offline-fixture-key' }
|
|
46
|
+
@agent_cfg[:plan_first] = false
|
|
47
|
+
runtime = double('enclosing request route')
|
|
48
|
+
expect(runtime).not_to receive(:call)
|
|
49
|
+
Thread.current[:pwn_request_runtime] = runtime
|
|
50
|
+
allow(PWN::AI::OpenAI).to receive(:chat_with_tools) do |opts|
|
|
51
|
+
system = opts[:messages].find { |row| row[:role].to_s == 'system' }[:content]
|
|
52
|
+
handoff = JSON.parse(system.split("SOLVE HANDOFF\n").last, symbolize_names: true)
|
|
53
|
+
phase = handoff[:phase]
|
|
54
|
+
revision = handoff[:revision]
|
|
55
|
+
content = case phase
|
|
56
|
+
when 'assess' then { requirements: ['Greeting bytes'], risks: [] }
|
|
57
|
+
when 'build'
|
|
58
|
+
next({ choices: [{ message: { role: 'assistant', content: nil, tool_calls: [{ id: 'write-greeting', type: 'function', function: { name: 'shell', arguments: JSON.generate(command: "printf hello > #{Shellwords.escape(File.join(@workspace, 'greeting.txt'))}", timeout: 5) } }] } }] }) unless File.exist?(File.join(@workspace, 'greeting.txt'))
|
|
59
|
+
|
|
60
|
+
{ artifacts: ['greeting.txt'], summary: 'written' }
|
|
61
|
+
when 'critique' then { revision: revision, objections: [] }
|
|
62
|
+
when 'verify' then { revision: revision, checks: [{ requirements: %w[R0 R1], command: 'cat greeting.txt', stdout: 'hello' }] }
|
|
63
|
+
when 'integrate' then { revision: revision, decision: 'accept', summary: 'Verified greeting' }
|
|
64
|
+
end
|
|
65
|
+
{ choices: [{ message: { role: 'assistant', content: JSON.generate(content), tool_calls: [] } }] }
|
|
66
|
+
end
|
|
67
|
+
require 'timeout'
|
|
68
|
+
controller = PWN::Plugins::REPL::AISwarm::Controller.new(session_id: 'real-loop-fixture')
|
|
69
|
+
out = Timeout.timeout(15) do
|
|
70
|
+
controller.execute(line: '/swarm mission Write hello to greeting.txt', workspace: @workspace)
|
|
71
|
+
controller.send(:workers).last.value
|
|
72
|
+
end
|
|
73
|
+
expect(out).to include(status: 'accepted')
|
|
74
|
+
expect(out[:evidence].first).to include(stdout: 'hello', exitstatus: 0)
|
|
75
|
+
ensure
|
|
76
|
+
controller&.close
|
|
77
|
+
Thread.current[:pwn_request_runtime] = nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it 'runs all roles with the unchanged request and accepts only executed evidence for the artifact revision' do
|
|
81
|
+
request = 'Write the greeting hello to greeting.txt'
|
|
82
|
+
states = []
|
|
83
|
+
out = PWN::AI::Agent::Swarm.solve(
|
|
84
|
+
request: request, workspace: @workspace, on_state: ->(state) { states << state },
|
|
85
|
+
on_tool: ->(*) { expect(states.last[:roles]['verifier']).to eq('checking') }
|
|
86
|
+
)
|
|
87
|
+
expect(out).to include(ok: true, status: 'accepted', request: request)
|
|
88
|
+
expect(@turns.map { |_, handoff| handoff[:phase] }).to eq(%w[assess build critique verify integrate])
|
|
89
|
+
expect(@turns.map { |opts, _| opts[:request] }.uniq).to eq([request])
|
|
90
|
+
expect(out[:evidence].first).to include(passed: true, stdout: 'hello', exitstatus: 0, revision: out[:revision])
|
|
91
|
+
expect(File.read(File.join(@workspace, 'greeting.txt'))).to eq('hello')
|
|
92
|
+
record = JSON.parse(File.read(out[:record_path]))
|
|
93
|
+
expect(record['revision']).to eq(out[:revision])
|
|
94
|
+
expect(record['evidence'].first['stdout']).to eq('hello')
|
|
95
|
+
expect(record['records']).to include(include('phase' => 'candidate', 'revision' => out[:revision], 'artifacts' => out[:artifacts]))
|
|
96
|
+
expect(out[:reply]).to include('R0', 'greeting.txt', out[:revision])
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it 'routes the legacy slash entry to an executed solve, not a normal model request' do
|
|
100
|
+
pry = Pry.new
|
|
101
|
+
controller = nil
|
|
102
|
+
Dir.chdir(@workspace) do
|
|
103
|
+
expect(PWN::Plugins::REPL.pwn_ai_dispatch_slash!(request: '/swarm mission Write hello to greeting.txt', pry: pry)).to be(true)
|
|
104
|
+
controller = pry.config.pwn_ai_swarm
|
|
105
|
+
expect(controller).to be_a(PWN::Plugins::REPL::AISwarm::Controller)
|
|
106
|
+
controller.send(:workers).each(&:join)
|
|
107
|
+
expect(controller.snapshot[:jobs].last[:state]).to eq('accepted')
|
|
108
|
+
end
|
|
109
|
+
ensure
|
|
110
|
+
controller&.close
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it 'binds identical artifact bytes to the specific objective and solve run' do
|
|
114
|
+
first = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace)
|
|
115
|
+
second = PWN::AI::Agent::Swarm.solve(request: 'Write hello and inspect it', workspace: @workspace)
|
|
116
|
+
expect(first[:artifacts]).to eq(second[:artifacts])
|
|
117
|
+
expect(first[:revision]).not_to eq(second[:revision])
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it 'rejects failing checks even when integration claims accept' do
|
|
121
|
+
@bad = true
|
|
122
|
+
@hook = ->(handoff, answer) { answer[:decision] = 'accept' if handoff[:phase] == 'integrate' }
|
|
123
|
+
out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace, rounds: 1)
|
|
124
|
+
expect(out).to include(status: 'incomplete', ok: false)
|
|
125
|
+
expect(out[:evidence].first).to include(stdout: 'wrong', exitstatus: 0, passed: false)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it 'repairs through the sole writer and reruns verification against changed bytes' do
|
|
129
|
+
@bad = @repair = true
|
|
130
|
+
out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace)
|
|
131
|
+
expect(out[:status]).to eq('accepted')
|
|
132
|
+
builds = @turns.select { |_, row| row[:phase] == 'build' }
|
|
133
|
+
expect(builds.length).to eq(2)
|
|
134
|
+
expect(builds.last.last[:repair]).to include(instruction: 'Write hello')
|
|
135
|
+
verifications = out[:records].select { |row| row[:phase] == 'verification' }
|
|
136
|
+
expect(verifications.map { |row| row[:evidence].first[:passed] }).to eq([false, true])
|
|
137
|
+
expect(out[:records].select { |row| row[:phase] == 'verify' }.map { |row| row[:revision] }.uniq.length).to eq(2)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
it 'does not accept unresolved objections or missing requirement coverage' do
|
|
141
|
+
@reject = true
|
|
142
|
+
out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace, rounds: 1)
|
|
143
|
+
expect(out[:status]).to eq('incomplete')
|
|
144
|
+
expect(out[:objections]).to eq(['Wrong output'])
|
|
145
|
+
@reject = false
|
|
146
|
+
@hook = ->(handoff, answer) { answer[:checks].first[:requirements] = ['R1'] if handoff[:phase] == 'verify' }
|
|
147
|
+
out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace, rounds: 1)
|
|
148
|
+
expect(out).to include(status: 'incomplete', uncovered: ['R0'])
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
it 'invalidates evidence if the working artifact changes during integration' do
|
|
152
|
+
@hook = ->(handoff, _answer) { File.write(File.join(@workspace, 'greeting.txt'), 'changed') if handoff[:phase] == 'integrate' }
|
|
153
|
+
expect(PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace, rounds: 1)[:status]).to eq('incomplete')
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
it 'rejects stale handoffs and copy-modifying verification' do
|
|
157
|
+
@hook = ->(handoff, answer) { answer[:revision] = 'old' if handoff[:phase] == 'critique' }
|
|
158
|
+
out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace)
|
|
159
|
+
expect(out[:reply]).to include('stale revision')
|
|
160
|
+
@hook = lambda do |handoff, answer|
|
|
161
|
+
answer[:checks].first[:command] = 'printf wrong > greeting.txt; printf hello' if handoff[:phase] == 'verify'
|
|
162
|
+
end
|
|
163
|
+
out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace, rounds: 1)
|
|
164
|
+
expect(out[:status]).to eq('incomplete')
|
|
165
|
+
expect(File.read(File.join(@workspace, 'greeting.txt'))).to eq('hello')
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
it 'propagates steering to every later role and invalidates the previous candidate' do
|
|
169
|
+
control = PWN::AI::Agent::Solve::Control.new(input: StringIO.new, output: StringIO.new)
|
|
170
|
+
steered = false
|
|
171
|
+
@hook = lambda do |handoff, _answer|
|
|
172
|
+
if handoff[:phase] == 'integrate' && !steered
|
|
173
|
+
control.submit('Also verify exact bytes')
|
|
174
|
+
steered = true
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace, steering: control)
|
|
178
|
+
expect(out[:status]).to eq('accepted')
|
|
179
|
+
expect(@turns.count { |_, row| row[:phase] == 'build' }).to eq(2)
|
|
180
|
+
expect(out[:requirements]).to include('S1' => 'Also verify exact bytes')
|
|
181
|
+
expect(@turns.last(4).all? { |opts, row| opts[:request] == 'Write hello' && row[:guidance] == ['Also verify exact bytes'] }).to be(true)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
it 'cancels at a tool boundary without claiming completion or undoing work' do
|
|
185
|
+
control = PWN::AI::Agent::Solve::Control.new(input: StringIO.new, output: StringIO.new)
|
|
186
|
+
@hook = ->(handoff, _answer) { control.stop if handoff[:phase] == 'build' }
|
|
187
|
+
out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace, steering: control)
|
|
188
|
+
expect(out[:status]).to eq('cancelled')
|
|
189
|
+
expect(File.read(File.join(@workspace, 'greeting.txt'))).to eq('hello')
|
|
190
|
+
expect(@turns.length).to eq(2)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
it 'refuses unconfigured providers before any role or login runs' do
|
|
194
|
+
PWN::Env[:ai][:active] = :openai
|
|
195
|
+
PWN::Env[:ai][:openai] = {}
|
|
196
|
+
expect(PWN::AI::Agent::Loop).not_to receive(:run)
|
|
197
|
+
out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace)
|
|
198
|
+
expect(out[:reply]).to include('configure openai credentials')
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
it 'enforces read-only reviewers at Dispatch even for invented tool calls' do
|
|
202
|
+
@hook = lambda do |handoff, _answer|
|
|
203
|
+
next unless handoff[:phase] == 'critique'
|
|
204
|
+
|
|
205
|
+
raw = PWN::AI::Agent::Dispatch.call(tool_call: { function: { name: 'shell', arguments: JSON.generate(command: "touch #{Shellwords.escape(File.join(@workspace, 'forbidden'))}") } })
|
|
206
|
+
expect(JSON.parse(raw)['success']).to be(false)
|
|
207
|
+
end
|
|
208
|
+
PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace)
|
|
209
|
+
expect(File).not_to exist(File.join(@workspace, 'forbidden'))
|
|
210
|
+
expect(Thread.current[:pwn_solve_tools]).to be_nil
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
it 'routes configured roles without switching defaults or modifying the global registry' do
|
|
214
|
+
PWN::AI::Agent::Swarm.spawn(name: 'solve_antagonist', role: 'Review', engine: :ollama, model: 'Exact/Reviewer:Tag')
|
|
215
|
+
registry = File.binread(PWN::AI::Agent::Swarm::AGENTS_FILE)
|
|
216
|
+
routes = []
|
|
217
|
+
@hook = ->(handoff, _answer) { routes << [handoff[:role], Thread.current[:pwn_swarm_engine], Thread.current[:pwn_swarm_model]] }
|
|
218
|
+
out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace)
|
|
219
|
+
expect(out[:status]).to eq('accepted')
|
|
220
|
+
expect(routes.select { |row| row.first == 'antagonist' }).to all(eq(['antagonist', 'ollama', 'Exact/Reviewer:Tag']))
|
|
221
|
+
expect(File.binread(PWN::AI::Agent::Swarm::AGENTS_FILE)).to eq(registry)
|
|
222
|
+
expect(Thread.current[:pwn_swarm_model]).to be_nil
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
it 'never opens OAuth or a credential prompt during a solve provider call' do
|
|
226
|
+
Thread.current[:pwn_solve_tools] = []
|
|
227
|
+
{ openai: [PWN::AI::OpenAI, :open_ai_rest_call], anthropic: [PWN::AI::Anthropic, :anthropic_rest_call], grok: [PWN::AI::Grok, :grok_rest_call] }.each do |engine, (provider, method)|
|
|
228
|
+
PWN::Env[:ai][engine] = {}
|
|
229
|
+
expect(provider).not_to receive(:obtain_oauth_bearer_token)
|
|
230
|
+
expect(PWN::Plugins::AuthenticationHelper).not_to receive(:mask_password)
|
|
231
|
+
expect(provider.send(method, rest_call: 'fixture')).to be_nil
|
|
232
|
+
end
|
|
233
|
+
ensure
|
|
234
|
+
Thread.current[:pwn_solve_tools] = nil
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
it 'pauses dispatch, resumes, and cancels through the coordinator command entry' do
|
|
238
|
+
entered = Queue.new
|
|
239
|
+
release = Queue.new
|
|
240
|
+
@hook = lambda do |handoff, _answer|
|
|
241
|
+
if handoff[:phase] == 'assess'
|
|
242
|
+
entered << true
|
|
243
|
+
release.pop
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
controller = PWN::Plugins::REPL::AISwarm::Controller.new(session_id: 'fixture')
|
|
247
|
+
job = controller.execute(line: '/swarm mission Write hello', workspace: @workspace)
|
|
248
|
+
entered.pop
|
|
249
|
+
expect(controller.execute(line: "/swarm pause #{job[:job_id]}")[:ok]).to be(true)
|
|
250
|
+
release << true
|
|
251
|
+
expect(controller.snapshot[:jobs].last[:state]).to eq('paused')
|
|
252
|
+
expect(controller.execute(line: '/swarm dm other write')[:ok]).to be(false)
|
|
253
|
+
expect(controller.execute(line: "/swarm steer #{job[:job_id]} Check bytes")[:ok]).to be(true)
|
|
254
|
+
expect(controller.execute(line: "/swarm resume #{job[:job_id]}")[:ok]).to be(true)
|
|
255
|
+
controller.execute(line: "/swarm cancel #{job[:job_id]}")
|
|
256
|
+
expect(controller.send(:workers).last.join(3)).not_to be_nil
|
|
257
|
+
expect(controller.snapshot[:jobs].last[:state]).to eq('cancelled')
|
|
258
|
+
ensure
|
|
259
|
+
release << true if release
|
|
260
|
+
controller&.close
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
it 'bounds a nonconverging role without inventing a final answer' do
|
|
264
|
+
control = PWN::AI::Agent::Solve::Control.new(input: StringIO.new, output: StringIO.new)
|
|
265
|
+
control.begin_turn
|
|
266
|
+
25.times { control.model(messages: []) { :fixture } }
|
|
267
|
+
expect { control.model(messages: []) { :unreachable } }.to raise_error(PWN::AI::Agent::Solve::Control::Limit)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
it 'runs a draft-preserving Mission with team through the curses confirmation entry' do
|
|
271
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
|
|
272
|
+
controller = PWN::Plugins::REPL::AISwarm::Controller.new(session_id: 'fixture')
|
|
273
|
+
console.instance_variable_set(:@swarm, controller)
|
|
274
|
+
editor = console.instance_variable_get(:@editor)
|
|
275
|
+
editor.place('Write hello to greeting.txt', 4)
|
|
276
|
+
Dir.chdir(@workspace) do
|
|
277
|
+
console.handle("\u0007")
|
|
278
|
+
console.handle('v')
|
|
279
|
+
expect(controller.snapshot[:jobs]).to be_empty
|
|
280
|
+
expect(console.swarm_content.join).to include('mission')
|
|
281
|
+
console.handle("\r")
|
|
282
|
+
controller.send(:workers).each(&:join)
|
|
283
|
+
expect(controller.snapshot[:jobs].last).to include(state: 'accepted', team: include(roles: include('verifier' => 'done')))
|
|
284
|
+
console.handle("\e")
|
|
285
|
+
expect([editor.text, editor.cursor]).to eq(['Write hello to greeting.txt', 4])
|
|
286
|
+
end
|
|
287
|
+
ensure
|
|
288
|
+
controller&.close
|
|
289
|
+
end
|
|
290
|
+
end
|