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.
@@ -0,0 +1,342 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+ require 'tmpdir'
6
+ require 'shellwords'
7
+ require 'stringio'
8
+
9
+ module PWN
10
+ module AI
11
+ module Agent
12
+ # Revision-bound, single-writer team coordinator. Role replies are proposals,
13
+ # never observations. Only host-executed checks can become evidence.
14
+ module Solve
15
+ ROLES = %w[protagonist antagonist verifier integrator].freeze
16
+
17
+ # Steering belongs to the objective, not to one child's replacement request.
18
+ class Control < Loop::Steering
19
+ class Stopped < Exception; end # rubocop:disable Lint/InheritException -- escape provider retry handlers
20
+ class Limit < Exception; end # rubocop:disable Lint/InheritException -- bounded role dispatch
21
+
22
+ def initialize(**args)
23
+ super
24
+ @solve_mutex = Mutex.new
25
+ @guidance = []
26
+ @paused = false
27
+ @stopped = false
28
+ end
29
+
30
+ def submit(instruction)
31
+ return if instruction.to_s.strip.empty?
32
+
33
+ @solve_mutex.synchronize { @guidance << instruction.to_s.dup }
34
+ end
35
+
36
+ def guidance
37
+ @solve_mutex.synchronize { @guidance.dup }
38
+ end
39
+
40
+ def begin_turn
41
+ @model_calls = 0
42
+ end
43
+
44
+ def model(messages:, &block)
45
+ check_stop
46
+ @model_calls = @model_calls.to_i + 1
47
+ raise Limit, 'Role model-call limit reached (25); objective remains incomplete.' if @model_calls > 25
48
+
49
+ super
50
+ end
51
+
52
+ def stop
53
+ @solve_mutex.synchronize { @stopped = true }
54
+ request_model_cancel
55
+ end
56
+
57
+ def pause(value)
58
+ @solve_mutex.synchronize { @paused = value }
59
+ end
60
+
61
+ def check_stop
62
+ loop do
63
+ stopped, paused = @solve_mutex.synchronize { [@stopped, @paused] }
64
+ raise Stopped, 'Solve cancelled at a safe boundary; completed work is not undone.' if stopped
65
+ break unless paused
66
+
67
+ sleep 0.05
68
+ end
69
+ end
70
+
71
+ def checkpoint(**options)
72
+ @phase = options[:phase] || :boundary
73
+ check_stop
74
+ rescue Stopped
75
+ yield if block_given?
76
+ raise
77
+ end
78
+ end
79
+
80
+ # Serial role dispatch ensures there is exactly one model writer.
81
+ class Run # rubocop:disable Metrics/ClassLength -- one revision-bound coordinator lifecycle
82
+ def initialize(opts)
83
+ @opts = opts
84
+ @request = opts[:request].to_s.dup.freeze
85
+ raise ArgumentError, 'request is required' if @request.strip.empty?
86
+
87
+ @workspace = File.realpath(opts[:workspace] || Dir.pwd)
88
+ @rounds = Integer(opts.fetch(:rounds, 3))
89
+ raise ArgumentError, 'rounds must be between 1 and 10' unless @rounds.between?(1, 10)
90
+
91
+ @control = opts[:steering] || Control.new(input: StringIO.new, output: StringIO.new)
92
+ raise ArgumentError, 'solve requires Solve::Control for coordinator-wide steering' unless @control.is_a?(Control)
93
+
94
+ @requirements = { 'R0' => @request }
95
+ @states = ROLES.to_h { |role| [role, 'waiting'] }
96
+ @records = []
97
+ @evidence = []
98
+ @objections = []
99
+ @artifacts = {}
100
+ @revision = nil
101
+ @repair = nil
102
+ @guidance = []
103
+ end
104
+
105
+ def run
106
+ prepare
107
+ assessment = turn('antagonist', 'assess')
108
+ Array(assessment.fetch(:requirements)).each_with_index { |text, i| @requirements["R#{i + 1}"] = text.to_s }
109
+ @assessment = assessment
110
+ @rounds.times do |index|
111
+ @round = index + 1
112
+ boundary
113
+ @guidance = guidance
114
+ @guidance.each_with_index { |instruction, i| @requirements["S#{i + 1}"] = instruction }
115
+ candidate = turn('protagonist', 'build')
116
+ @artifacts = observe(candidate.fetch(:artifacts))
117
+ raise ArgumentError, 'candidate must declare at least one existing artifact' if @artifacts.empty?
118
+
119
+ @revision = digest(run: @sid, round: @round, request: @request, requirements: @requirements, artifacts: @artifacts, guidance: @guidance)
120
+ @records << { role: 'coordinator', phase: 'candidate', revision: @revision, artifacts: @artifacts.dup, round: @round }
121
+ @candidate = candidate
122
+ critique = turn('antagonist', 'critique')
123
+ @objections = Array(critique.fetch(:objections))
124
+ verification = turn('verifier', 'verify')
125
+ @evidence = execute_checks(verification.fetch(:checks))
126
+ integration = turn('integrator', 'integrate')
127
+ boundary
128
+ return result('accepted', integration[:summary].to_s) if integration[:decision] == 'accept' && acceptable?
129
+
130
+ @repair = { instruction: integration[:repair], objections: @objections, evidence: @evidence,
131
+ stale: !current?, uncovered: uncovered }
132
+ end
133
+ result('incomplete', 'Round limit reached; unresolved, stale, or unverified requirements remain.')
134
+ rescue Control::Stopped => e
135
+ result('cancelled', e.message)
136
+ rescue Control::Limit => e
137
+ result('incomplete', e.message)
138
+ rescue StandardError => e
139
+ result('incomplete', "#{e.class}: #{e.message}")
140
+ ensure
141
+ @lock&.flock(File::LOCK_UN)
142
+ @lock&.close
143
+ end
144
+
145
+ private
146
+
147
+ def prepare
148
+ Registry.discover
149
+ # A file lock also excludes another process's solve writer in this workspace.
150
+ lock_path = File.join(Dir.tmpdir, "pwn-solve-#{Digest::SHA256.hexdigest(@workspace)}.lock")
151
+ @lock = File.open(lock_path, File::RDWR | File::CREAT, 0o600)
152
+ raise 'another solve owns this workspace' unless @lock.flock(File::LOCK_EX | File::LOCK_NB)
153
+
154
+ configured = Swarm.personas(swarm_id: @opts[:swarm_id])
155
+ @sid = Swarm.create(topic: @request)[:swarm_id]
156
+ @personas = ROLES.to_h do |role|
157
+ persona = configured[:"solve_#{role}"] || {}
158
+ engine = (persona[:engine] || Thread.current[:pwn_swarm_engine] || PWN::Env.dig(:ai, :active)).to_s
159
+ raise ArgumentError, "unsupported solve provider: #{engine}" unless Loop::ENGINE_MODS.key?(engine.to_sym)
160
+
161
+ provider = PWN::Env.dig(:ai, engine.to_sym) || {}
162
+ credentials = [provider[:key], provider.dig(:oauth, :bearer_token), provider.dig(:oauth, :refresh_token)]
163
+ raise ArgumentError, "solve_#{role}: configure #{engine} credentials first; solve never starts a login" if engine != 'ollama' && credentials.none? { |value| !value.to_s.strip.empty? && !value.to_s.match?(/\A(?:optional|required)\s*-/) }
164
+
165
+ [role, persona.merge(engine: engine, toolsets: role == 'protagonist' ? %w[terminal pwn] : [],
166
+ role: "#{persona[:role]}\nBuilt-in solve #{role}. Follow the structured handoff; original request is the sole objective.")]
167
+ end
168
+ end
169
+
170
+ def turn(role, phase)
171
+ boundary
172
+ @control.begin_turn
173
+ @states[role] = phase
174
+ emit
175
+ payload = { phase: phase, role: role, original_request: @request, requirements: @requirements,
176
+ workspace: @workspace, round: @round, revision: @revision, guidance: guidance,
177
+ assessment: @assessment, candidate: @candidate, artifacts: artifact_text,
178
+ objections: @objections, evidence: @evidence, repair: @repair }
179
+ prior = Thread.current[:pwn_solve_tools]
180
+ Thread.current[:pwn_solve_tools] = role == 'protagonist' ? %w[shell pwn_eval] : []
181
+ reply = Swarm.ask(name: "solve_#{role}", persona: @personas.fetch(role), request: @request,
182
+ swarm_id: @sid, handoff: "#{contract(phase)}\nSOLVE HANDOFF\n#{JSON.generate(payload)}",
183
+ steering: @control, usage_observer: @opts[:usage_observer], on_tool: @opts[:on_tool])
184
+ row = JSON.parse(reply.fetch(:reply), symbolize_names: true)
185
+ raise ArgumentError, "#{role} handoff must be an object" unless row.is_a?(Hash)
186
+ raise ArgumentError, "#{role} returned a stale revision" if %w[critique verify integrate].include?(phase) && row[:revision] != @revision
187
+
188
+ @records << { role: role, phase: phase, revision: @revision, data: row }
189
+ @states[role] = 'done'
190
+ emit
191
+ row
192
+ ensure
193
+ Thread.current[:pwn_solve_tools] = prior
194
+ end
195
+
196
+ def contract(phase)
197
+ shapes = {
198
+ 'assess' => '{"requirements":["every atomic requirement in original request"],"risks":["independent risk assessment"]}',
199
+ 'build' => '{"artifacts":["relative/file"],"summary":"what changed"}',
200
+ 'critique' => '{"revision":"exact revision","objections":["unresolved correctness issue; empty only if none"]}',
201
+ 'verify' => '{"revision":"exact revision","checks":[{"requirements":["R0","R1"],"command":"real test command","stdout":"exact expected nonempty stdout"}]}',
202
+ 'integrate' => '{"revision":"exact revision","decision":"accept or repair","repair":"targeted repair for protagonist","summary":"integrated result"}'
203
+ }
204
+ "Return ONLY a JSON object matching #{shapes.fetch(phase)}. Do not replace the objective. " \
205
+ 'Protagonist alone may change files. Review roles have no tool access. Verification commands run by the coordinator ' \
206
+ 'in a disposable copy of declared artifacts, not the working tree; declare all test dependencies as artifacts. ' \
207
+ 'Checks must test the named requirements, not print a success claim. R0 covers the ENTIRE original request. ' \
208
+ 'Never accept missing evidence, unresolved objections, or omitted requirements. Integration requests repairs; it does not write.'
209
+ end
210
+
211
+ def observe(paths)
212
+ Array(paths).to_h do |relative|
213
+ raise ArgumentError, 'artifact path must be relative' unless relative.is_a?(String) && !relative.start_with?('/')
214
+
215
+ path = File.realpath(File.join(@workspace, relative))
216
+ raise ArgumentError, "artifact outside workspace: #{relative}" unless path.start_with?("#{@workspace}/")
217
+ raise ArgumentError, "artifact must be a regular file: #{relative}" unless File.file?(path)
218
+ raise ArgumentError, "artifact symlink is not supported: #{relative}" unless path == File.expand_path(relative, @workspace)
219
+
220
+ [relative, Digest::SHA256.file(path).hexdigest]
221
+ end
222
+ end
223
+
224
+ def artifact_text
225
+ @artifacts.to_h do |path, sha|
226
+ bytes = File.binread(File.join(@workspace, path))
227
+ raise ArgumentError, "artifact too large for review: #{path} (limit 128 KiB)" if bytes.bytesize > 131_072
228
+
229
+ [path, { sha256: sha, text: bytes.encode('UTF-8', invalid: :replace, undef: :replace) }]
230
+ end
231
+ end
232
+
233
+ def execute_checks(checks)
234
+ raise ArgumentError, 'verifier must supply 1..32 checks' unless checks.is_a?(Array) && checks.length.between?(1, 32)
235
+
236
+ @states['verifier'] = 'checking'
237
+ emit
238
+ observations = checks.map do |check|
239
+ boundary
240
+ ids = Array(check[:requirements]).map(&:to_s)
241
+ raise ArgumentError, 'check must name known requirements' if ids.empty? || (ids - @requirements.keys).any?
242
+ raise ArgumentError, 'check needs a command and nonempty expected stdout' if check[:command].to_s.strip.empty? || check[:stdout].to_s.empty?
243
+
244
+ execute_check(check, ids)
245
+ end
246
+ @states['verifier'] = 'done'
247
+ @records << { role: 'verifier', phase: 'verification', revision: @revision, evidence: observations }
248
+ emit
249
+ observations
250
+ end
251
+
252
+ def execute_check(check, ids)
253
+ Dir.mktmpdir('pwn-solve-verify') do |copy|
254
+ @artifacts.each_key do |path|
255
+ destination = File.expand_path(path, copy)
256
+ raise ArgumentError, 'invalid artifact copy path' unless destination.start_with?("#{copy}/")
257
+
258
+ FileUtils.mkdir_p(File.dirname(destination))
259
+ FileUtils.cp(File.join(@workspace, path), destination)
260
+ end
261
+ copied = @artifacts.to_h { |path, _sha| [path, Digest::SHA256.file(File.join(copy, path)).hexdigest] }
262
+ raise 'candidate changed while copying verification inputs' unless copied == @artifacts
263
+
264
+ prior = Thread.current[:pwn_solve_tools]
265
+ Thread.current[:pwn_solve_tools] = ['shell']
266
+ command = "cd #{Shellwords.escape(copy)} && (#{check[:command]})"
267
+ raw = Dispatch.call(tool_call: { id: SecureRandom.hex(8), function: { name: 'shell', arguments: JSON.generate(command: command, timeout: 60) } })
268
+ @opts[:on_tool]&.call('shell', { command: command }, raw)
269
+ response = JSON.parse(raw, symbolize_names: true)
270
+ observed = response[:result].is_a?(Hash) ? response[:result] : {}
271
+ unchanged = @artifacts.all? { |path, sha| File.file?(File.join(copy, path)) && Digest::SHA256.file(File.join(copy, path)).hexdigest == sha }
272
+ { requirements: ids, revision: @revision, command: check[:command], stdout: observed[:stdout],
273
+ stderr: observed[:stderr], exitstatus: observed[:exit], raw: raw,
274
+ passed: response[:success] == true && observed[:exit].is_a?(Integer) && observed[:exit].zero? && observed[:stdout] == check[:stdout] && unchanged && current? }
275
+ ensure
276
+ Thread.current[:pwn_solve_tools] = prior
277
+ end
278
+ end
279
+
280
+ def current?
281
+ guidance == @guidance && observe(@artifacts.keys) == @artifacts
282
+ rescue StandardError
283
+ false
284
+ end
285
+
286
+ def uncovered
287
+ @requirements.keys - @evidence.select { |row| row[:passed] && row[:revision] == @revision }.flat_map { |row| row[:requirements] }
288
+ end
289
+
290
+ def acceptable?
291
+ !@evidence.empty? && @evidence.all? { |row| row[:passed] } && uncovered.empty? && @objections.empty? && current?
292
+ end
293
+
294
+ def digest(value)
295
+ Digest::SHA256.hexdigest(JSON.generate(value))
296
+ end
297
+
298
+ def guidance
299
+ @control.respond_to?(:guidance) ? @control.guidance : []
300
+ end
301
+
302
+ def boundary
303
+ @control&.check_stop
304
+ end
305
+
306
+ def emit
307
+ @opts[:on_state]&.call({ roles: @states.dup, request: @request, requirements: @requirements.dup,
308
+ artifacts: @artifacts.dup, objections: @objections.dup, round: @round, revision: @revision,
309
+ routing: (@personas || {}).transform_values { |row| row.slice(:engine, :model) } })
310
+ end
311
+
312
+ def result(status, summary)
313
+ @states.transform_values! { |state| %w[waiting done].include?(state) ? state : status }
314
+ emit
315
+ path = File.join(Swarm::SWARM_ROOT, @sid, 'solve.json') if @sid
316
+ report = ["#{status.upcase}: #{summary}", "Original request: #{@request}", "Revision: #{@revision || 'none'}",
317
+ "Artifacts: #{@artifacts.keys.join(', ')}", "Evidence coverage: #{(@requirements.keys - uncovered).join(', ')}",
318
+ "Unverified: #{uncovered.join(', ')}", "Open objections: #{@objections.join('; ')}", "Record: #{path}"].join("\n")
319
+ outcome = { ok: status == 'accepted', status: status, request: @request, swarm_id: @sid, revision: @revision,
320
+ requirements: @requirements, artifacts: @artifacts, objections: @objections, evidence: @evidence,
321
+ records: @records, uncovered: uncovered, reply: report, record_path: path }
322
+ File.write(path, JSON.pretty_generate(outcome), mode: 'w', perm: 0o600) if path
323
+ outcome
324
+ end
325
+ end
326
+
327
+ public_class_method def self.authors
328
+ 'AUTHOR(S): 0day Inc. <support@0dayinc.com>'
329
+ end
330
+
331
+ public_class_method def self.help
332
+ puts "USAGE:
333
+ # Run a revision-bound team through the public Swarm solve entry point.
334
+ PWN::AI::Agent::Swarm.solve(request: 'required - original objective')
335
+ # Return module authors.
336
+ #{self}.authors
337
+ "
338
+ end
339
+ end
340
+ end
341
+ end
342
+ end
@@ -237,7 +237,7 @@ module PWN
237
237
  prior_id = Thread.current[:pwn_swarm_id]
238
238
  prior_honesty = Thread.current[:pwn_swarm_honesty]
239
239
  sid = opts[:swarm_id] || create(topic: opts[:request].to_s[0, 60])[:swarm_id]
240
- persona = personas(swarm_id: sid)[name.to_sym]
240
+ persona = opts[:persona] || personas(swarm_id: sid)[name.to_sym]
241
241
  raise ArgumentError, "unknown persona: #{name} (see #{AGENTS_FILE})" unless persona
242
242
 
243
243
  if opts[:unit].to_s != ''
@@ -256,6 +256,7 @@ module PWN
256
256
  session_id = persona_session(swarm_id: sid, name: name)
257
257
  sys = build_persona_prompt(name: name, persona: persona,
258
258
  swarm_id: sid, session_id: session_id)
259
+ sys = "#{sys}\n#{opts[:handoff]}" if opts[:handoff]
259
260
 
260
261
  empty_tools = opts[:text_only] == true || Array(persona[:toolsets]).empty?
261
262
  Thread.current[:pwn_swarm_depth] = depth + 1
@@ -269,6 +270,7 @@ module PWN
269
270
  enabled_toolsets: empty_tools ? [] : persona[:toolsets],
270
271
  core_only: empty_tools ? false : true,
271
272
  system_role_content: sys,
273
+ solve_handoff: !opts[:handoff].nil?,
272
274
  on_tool: opts[:on_tool],
273
275
  steering: opts[:steering],
274
276
  nested: true
@@ -354,6 +356,30 @@ module PWN
354
356
  { swarm_id: sid, replies: replies }
355
357
  end
356
358
 
359
+ # Run the built-in four-role workflow with host-observed verification.
360
+ # Supported Method Parameters::
361
+ # PWN::AI::Agent::Swarm.solve(
362
+ # request: 'required - original objective, preserved for every role',
363
+ # workspace: 'optional - artifact root, defaults to current directory',
364
+ # rounds: 'optional - bounded repair rounds, 1..10, default 3',
365
+ # swarm_id: 'optional - existing swarm registry',
366
+ # steering: 'optional - Solve::Control coordinator control',
367
+ # on_state: 'optional - observed role state callback',
368
+ # on_tool: 'optional - tool observation callback',
369
+ # usage_observer: 'optional - provider usage callback'
370
+ # )
371
+ public_class_method def self.solve(opts = {})
372
+ prior_id = Thread.current[:pwn_swarm_id]
373
+ prior_honesty = Thread.current[:pwn_swarm_honesty]
374
+ raise ArgumentError, 'request is required' if opts[:request].to_s.strip.empty?
375
+
376
+ Thread.current[:pwn_swarm_honesty] = nil
377
+ Solve::Run.new(opts).run
378
+ ensure
379
+ Thread.current[:pwn_swarm_id] = prior_id
380
+ Thread.current[:pwn_swarm_honesty] = prior_honesty
381
+ end
382
+
357
383
  # ------------------------------------------------------------------
358
384
  # privates
359
385
  # ------------------------------------------------------------------
@@ -754,6 +780,8 @@ module PWN
754
780
  on_tool: 'optional - ->(name, args, result) live-UI callback',
755
781
  steering: 'optional - Loop::Steering for this persona turn',
756
782
  usage_observer: 'optional - callable receiving provider usage',
783
+ persona: 'optional - explicit in-memory persona for the built-in coordinator',
784
+ handoff: 'optional - structured coordinator context, not a replacement request',
757
785
  from: 'optional - sender account or address to bind as operator (defaults to caller_label)',
758
786
  text_only: 'required - text only value consumed by #ask',
759
787
  unit: 'optional - claim key (host+phase) before the child runs',
@@ -761,6 +789,18 @@ module PWN
761
789
  engagement_id: 'optional - claim namespace (defaults to swarm_id)'
762
790
  )
763
791
 
792
+ # Solve the unchanged objective with four roles and executed evidence.
793
+ #{self}.solve(
794
+ request: 'required - original objective preserved for every role',
795
+ workspace: 'optional - artifact root, defaults to current directory',
796
+ rounds: 'optional - repair round cap, 1..10, default 3',
797
+ swarm_id: 'optional - registry from which role overrides are inherited',
798
+ steering: 'optional - Solve::Control for coordinator steering/cancellation/pause',
799
+ on_state: 'optional - observed four-role state callback',
800
+ on_tool: 'optional - observed tool callback',
801
+ usage_observer: 'optional - provider usage callback'
802
+ )
803
+
764
804
  # Run debate and return its result
765
805
  #{self}.debate(
766
806
  names: 'required - Array of persona names, order = speaking order',
data/lib/pwn/ai/agent.rb CHANGED
@@ -36,6 +36,7 @@ module PWN
36
36
  autoload :Extrospection, 'pwn/ai/agent/extrospection'
37
37
  autoload :Reflect, 'pwn/ai/agent/reflect'
38
38
  autoload :Swarm, 'pwn/ai/agent/swarm'
39
+ autoload :Solve, 'pwn/ai/agent/solve'
39
40
  autoload :Reward, 'pwn/ai/agent/reward'
40
41
  autoload :Verification, 'pwn/ai/agent/verification'
41
42
  autoload :Curriculum, 'pwn/ai/agent/curriculum'
@@ -325,6 +325,7 @@ module PWN
325
325
  # )
326
326
 
327
327
  private_class_method def self.anthropic_rest_call(opts = {})
328
+ opts = opts.merge(non_interactive: true) if Thread.current[:pwn_solve_tools]
328
329
  engine = PWN::Env[:ai][:anthropic] if defined?(PWN::Env)
329
330
  raise 'ERROR: Anthropic Hash not found in PWN::Env. Run `pwn -Y default.yaml`, then `PWN::Env` for usage.' if engine.nil?
330
331
 
data/lib/pwn/ai/grok.rb CHANGED
@@ -345,6 +345,7 @@ module PWN
345
345
  # spinner: 'optional - display spinner (defaults to false)'
346
346
  # )
347
347
  private_class_method def self.grok_rest_call(opts = {})
348
+ opts = opts.merge(non_interactive: true) if Thread.current[:pwn_solve_tools]
348
349
  engine = PWN::Env.dig(:ai, :grok) if defined?(PWN::Env)
349
350
  raise 'ERROR: Grok Hash not found in PWN::Env. Run `pwn -Y default.yaml`, then `PWN::Env` for usage.' if engine.nil?
350
351
 
@@ -391,6 +391,7 @@ module PWN
391
391
  # )
392
392
 
393
393
  private_class_method def self.open_ai_rest_call(opts = {})
394
+ opts = opts.merge(non_interactive: true) if Thread.current[:pwn_solve_tools]
394
395
  engine = PWN::Env[:ai][:openai] if defined?(PWN::Env)
395
396
  raise 'ERROR: OpenAI Hash not found in PWN::Env. Run `pwn -Y default.yaml`, then `PWN::Env` for usage.' if engine.nil?
396
397