pwn 0.5.761 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6748541ed45249f60331d0a819803c7c872527b763806edbcdd601ca30e862dc
4
- data.tar.gz: e9f1ff039dafe19a3f250df08896bdf2498f6c5550d28dae4c83e2a071560c58
3
+ metadata.gz: 20f94659af48e0dba30534ed36cfaf8942497dcc5851e346a44ef40c51abb130
4
+ data.tar.gz: 8f91abcce730d25f6721d89aa6389090aa572b40ffe7449cd419a874d15d1932
5
5
  SHA512:
6
- metadata.gz: 4c07ed5325a0db30c3933f8157e72b65e1f1ff3428e1944fb43f5809390cc647486fd9ed36b76c3986a2c55f75fba0d49c552ab3a1cb4b6dc1dc1e7669e3615f
7
- data.tar.gz: 880e597ef1c96a3cc197bdf56b317f1e0854d17ffafcf7780c6f97ca198bce7dbcda6c971bdf0fba0d2feea29de7794f6e7af1cf9d17d99471e325e19db9582a
6
+ metadata.gz: 8174ba5855b3cb25e41eabe9c34a57add502f6b1d52caef2809bd603e66eab5dd1a169e01e1e06feb5c745ffddaa817fbaae9748458933c35c727a5a9c18b736
7
+ data.tar.gz: 111e5b05d8ad0c54f82ba0ae1836fb9ab1f8f3c1845c992d2dbacf7a241286e6ea139c9714d91e58ee80a246a119bdadf59e71d6a0f65e30fc224b96a9de2b7a
data/README.md CHANGED
@@ -278,12 +278,12 @@ If this project helped you and you want to support the work, keep us caffeinated
278
278
 
279
279
  ### [**0x004D65726368**](https://0day.myspreadshop.com/) ###
280
280
 
281
- [![PWN Sticker](https://image.spreadshirtmedia.com/image-server/v1/products/T1459A839PA3861PT28D1044068794FS8193/views/1,width=300,height=300,appearanceId=839,backgroundColor=000000/ultimate-hacker-t-shirt-to-convey-to-the-public-a-hackers-favorite-past-time.jpg)](https://0day.myspreadshop.com/stickers)
281
+ [![PWN Sticker](https://image.spreadshirtmedia.com/image-server/v1/products/T1459A839PA3861PT28D1044068794W10000H3537/views/1,width=300,height=300,appearanceId=839,backgroundColor=000000/ultimate-hacker-t-shirt-to-convey-to-the-public-a-hackers-favorite-past-time.jpg)](https://0day.myspreadshop.com/home+%26+living+stickers+%26+magnets)
282
282
 
283
- [![Coffee Mug](https://image.spreadshirtmedia.com/image-server/v1/products/T1313A1PA3933PT10X2Y25D1020472680FS6327/views/3,width=300,height=300,appearanceId=1,backgroundColor=000000/https0dayinccom.jpg)](https://0day.myspreadshop.com/accessories+mugs+%26+drinkware)
283
+ [![Coffee Mug](https://image.spreadshirtmedia.com/image-server/v1/products/T1313A1PA3933PT10X3Y20D1044068794W5491H1942/views/3,width=300,height=300,appearanceId=1,backgroundColor=000000,crop=detail,modelId=11707/ultimate-hacker-t-shirt-to-convey-to-the-public-a-hackers-favorite-past-time.jpg)](https://0day.myspreadshop.com/home+%26+living+mugs+%26+drinkwear)
284
284
 
285
- [![Mouse Pad](https://image.spreadshirtmedia.com/image-server/v1/products/T993A1PA2168PT10X162Y26D1044068794S100/views/1,width=300,height=300,appearanceId=1,backgroundColor=000000/ultimate-hacker-t-shirt-to-convey-to-the-public-a-hackers-favorite-past-time.jpg)](https://0day.myspreadshop.com/accessories)
285
+ [![Mouse Pad](https://image.spreadshirtmedia.com/image-server/v1/products/T993A1PA2168PT10X162Y26D1044068794W6102H2158/views/1,width=300,height=300,appearanceId=1,backgroundColor=000000/ultimate-hacker-t-shirt-to-convey-to-the-public-a-hackers-favorite-past-time.jpg)](https://0day.myspreadshop.com/home+%26+living+other)
286
286
 
287
- [![0day Inc.](https://image.spreadshirtmedia.com/image-server/v1/products/T951A550PA3076PT17X0Y73D1020472680FS8515/views/1,width=300,height=300,appearanceId=70,backgroundColor=000000/https0dayinccom.jpg)](https://shop.spreadshirt.com/0day/0dayinc-A5c3e498cf937643162a01b5f?productType=951&appearance=70)
287
+ [![0day Inc.](https://image.spreadshirtmedia.com/image-server/v1/products/T803A2PA1648PT32X20Y18D1020472680W8807H1441/views/1,width=300,height=300,appearanceId=2,backgroundColor=000000,crop=detail,modelId=1723/https0dayinccom.jpg)](https://0day.myspreadshop.com/0dayinc-A5c3e498cf937643162a01b5f?productType=803&sellable=1naaRYxY1zhOoQlv7Mdg-803-34&appearance=2&size=29)
288
288
 
289
- [![Black Fingerprint Hoodie](https://image.spreadshirtmedia.com/image-server/v1/products/T111A2PA3208PT17X169Y51D1020472728FS6268/views/1,width=300,height=300,appearanceId=2/https0dayinccom.jpg)](https://shop.spreadshirt.com/0day/blackfingerprint-A5c3e49db1cbf3a0b9596b4d0?productType=111&appearance=2)
289
+ [![Black Fingerprint Hoodie](https://image.spreadshirtmedia.com/image-server/v1/products/T111A2PA4247PT17X198Y27D1020472728W12744H19015/views/1,width=300,height=300,appearanceId=5,backgroundColor=000000,crop=detail,modelId=1159/https0dayinccom.jpg)](https://0day.myspreadshop.com/blackfingerprint-A5c3e49db1cbf3a0b9596b4d0?productType=111&sellable=ekXXmGonOyclynglNE87-111-22&appearance=5)
@@ -18,6 +18,119 @@ self-improvement loop covers the whole swarm.
18
18
  No daemon. Cross-session / cross-process communication == another `pwn-ai` (or
19
19
  a `PWN::Cron` job) calling `Swarm.ask` with the same `swarm_id`.
20
20
 
21
+ ## Run one mission with a built-in team
22
+
23
+ ```text
24
+ /swarm mission Write and test a Ruby function that validates the supplied data format
25
+ ```
26
+
27
+ No persona setup is required. The exact text after `/swarm mission ` remains the
28
+ user request for **every** role; role instructions and JSON handoffs go in system
29
+ context, not a replacement task. Empty requests fail before dispatch. Existing
30
+ `dm`, `broadcast`, and `debate` retain their messaging semantics. `/swarm agents`
31
+ lists available personas; `/swarm dm NAME REQUEST` messages one persona.
32
+ The renamed slash commands replace the previous spellings without compatibility
33
+ aliases. Ruby APIs `Swarm.solve`, `Swarm.ask`, and the `Solve` coordinator are unchanged.
34
+
35
+ `/menu` and Tab use case-insensitive alphabetical ordering at every depth:
36
+ root commands, command actions, and live parameter lists (including agents,
37
+ jobs, MCP tools, sessions, skills, memory keys, and cron jobs). Exact spelling
38
+ breaks case-insensitive ties; selections keep their original command payloads.
39
+
40
+ 1. **Antagonist** independently enumerates requirements and risks before seeing
41
+ a candidate. `R0` always contains the entire unchanged original request.
42
+ 2. **Protagonist** builds the candidate using the ordinary nested tool-calling
43
+ `Loop.run` through `Swarm.ask`, then declares relative artifact paths.
44
+ 3. **Antagonist** critiques the candidate and returns unresolved objections.
45
+ 4. **Verifier** proposes requirement-indexed commands and exact expected stdout.
46
+ The coordinator executes them via the registered `shell` tool and `Dispatch`,
47
+ capturing actual stdout, stderr, and exit status. Model-supplied observations
48
+ are never accepted as execution evidence.
49
+ 5. **Integrator** reviews those observations and either accepts or sends a
50
+ targeted repair to the protagonist. Repairs always repeat critique and tests.
51
+
52
+ Acceptance additionally requires every requirement ID to have passing executed
53
+ evidence, no open objections, every check to exit zero with its expected output,
54
+ and unchanged artifact hashes and steering revision. A model's `accept` alone
55
+ cannot complete the job. Candidate and handoff revisions are SHA-256-bound;
56
+ changed files, stale handoffs, failed checks, uncovered requirements, malformed
57
+ JSON, provider failures and limits return **INCOMPLETE**, never success. Structured
58
+ records and raw execution observations are saved with owner-only permissions in
59
+ `~/.pwn/swarm/<solve-id>/solve.json`. Every solve gets fresh role sessions, so an
60
+ earlier candidate cannot contaminate the independent initial assessment.
61
+
62
+ ### Console and controls
63
+
64
+ Open the swarm workspace with **Ctrl+S / Ctrl+G**, keep a mission in the composer,
65
+ then press **v — Mission with team**. Enter confirms the exact draft and built-in
66
+ roles; Esc aborts without changing the draft or cursor. The jobs detail view
67
+ shows actual role phases, original requirements, provider/model routes, artifact
68
+ hashes, and open objections. There is one integrated final result. Provider usage
69
+ continues through the console's existing usage observer (no invented estimates).
70
+
71
+ ```text
72
+ /swarm status JOB
73
+ /swarm steer JOB Add this verification constraint
74
+ /swarm pause JOB
75
+ /swarm resume JOB
76
+ /swarm cancel JOB
77
+ ```
78
+
79
+ The same slash routes work in the legacy line console. Steering belongs to the
80
+ coordinator and is propagated to later roles; it invalidates previously accepted
81
+ candidate evidence without replacing the original goal. Pause stops future
82
+ dispatch at safe boundaries. Cancel interrupts the owned model wait; an already
83
+ running tool finishes first, and its changes are not undone. Another job cannot
84
+ start in the same controller while solve owns the workspace. A workspace file
85
+ lock also excludes overlapping solve coordinators across processes.
86
+
87
+ ### Role configuration and limits
88
+
89
+ All roles inherit the already selected provider and that provider's configured
90
+ default model. Optional overrides use **existing** persona fields; no new vault
91
+ configuration keys or schema migration is needed:
92
+
93
+ ```text
94
+ /swarm spawn solve_antagonist Review correctness --engine ollama --model YOUR_INSTALLED_MODEL
95
+ ```
96
+
97
+ The other names are `solve_protagonist`, `solve_verifier`, and `solve_integrator`.
98
+ They can also be defined in `agents.yml`. Engine/model overrides are request-local;
99
+ there is no superiority ranking, automatic diversity, or fallback to a different
100
+ provider. Remote credentials must already be configured. Solve never initiates
101
+ OAuth login or prompts for API keys; configured OAuth refresh remains the provider's
102
+ normal noninteractive operation. Implicit local-agent escalation is disabled for
103
+ solve. A missing provider fails visibly rather than silently switching.
104
+
105
+ Ruby API: `Swarm.solve(request:, workspace: Dir.pwd, rounds: 3, swarm_id: nil,
106
+ steering: nil, on_state: nil, on_tool: nil, usage_observer: nil)`. `swarm_id` supplies
107
+ role overrides; the returned solve ID identifies the independent records. The
108
+ default round limit is 3 (API range 1–10), each role turn is limited to 25 model
109
+ calls, and each of at most 32 checks per round has a 60-second tool timeout.
110
+
111
+ ### Boundaries, not a security sandbox
112
+
113
+ The protagonist is the only model role with write-capable tools (`shell` and
114
+ `pwn_eval`). Review roles have no tools; Dispatch denies invented tool calls even
115
+ if a provider ignores its empty tool schema. Integrator repairs are dispatched
116
+ back to that one writer. This is role capability enforcement, **not** confinement
117
+ of arbitrary protagonist Ruby or shell code or other processes on the host.
118
+
119
+ Verification runs in fresh disposable copies of declared regular-file artifacts;
120
+ all test dependencies must be declared. Symlinks/out-of-workspace artifacts are
121
+ rejected, and input copies must retain their candidate hashes after a check.
122
+ Files over 128 KiB cannot currently be passed to full-content review and produce
123
+ an explicit incomplete result. A disposable directory is **not an OS sandbox**:
124
+ test code can use absolute paths, network, installed dependencies and host resources.
125
+ Existing Dispatch confirmation and `ai_sandbox` policies still apply. Use only
126
+ trusted test commands or supply external OS isolation when executing untrusted code.
127
+
128
+ Requirement extraction and test relevance still require model judgment. Executed
129
+ checks are evidence for their stated assertions, not mathematical proof of an
130
+ arbitrary natural-language goal; the saved commands, outputs, requirements and
131
+ revisions make that judgment inspectable. No live-provider reliability or automatic
132
+ security isolation is implied by the deterministic offline integration tests.
133
+
21
134
  ## Define personas
22
135
 
23
136
  Each persona accepts optional `engine` and `model` keys. `model` is the exact
@@ -300,7 +300,7 @@ and Ctrl+D still work. A running tool finishes cooperatively: neither rollback
300
300
  nor remote provider billing cancellation is promised. Swarm jobs should use
301
301
  noninteractive tools; their individual stdin prompts are not multiplexed.
302
302
 
303
- Typed `/swarm roster|status|create|use|spawn|retire|ask|broadcast|debate|tail|steer|cancel`
303
+ Typed `/swarm agents|mission|status|create|use|spawn|retire|dm|broadcast|debate|tail|steer|pause|resume|cancel`
304
304
  commands remain available, including parameter completion. These are direct
305
305
  operator commands and do not add the workspace's extra confirmation step.
306
306
  `/swarm status JOB` includes retained outcomes. Jobs shown here belong to this
@@ -0,0 +1,46 @@
1
+ ---
2
+ name: pwn-ai-agent-solve
3
+ description: Drive PWN::AI::Agent::Solve from pwn_eval.
4
+ license: MIT
5
+ allowed-tools: [pwn, pwn_eval]
6
+ metadata:
7
+ bundled: true
8
+ generated: true
9
+ module: PWN::AI::Agent::Solve
10
+ source: pwn/ai/agent/solve.rb
11
+ ---
12
+
13
+ # PWN::AI::Agent::Solve
14
+
15
+ Revision-bound, single-writer team coordinator. Role replies are proposals, never observations. Only host-executed checks can become evidence.
16
+
17
+ ## When to use
18
+
19
+ Call `PWN::AI::Agent::Solve` from `pwn_eval` when the task needs this module.
20
+ Do not reimplement it in shell.
21
+
22
+ ## Methodologies
23
+
24
+ Generated from `pwn/ai/agent/solve.rb`. Prefer the public class methods below.
25
+ Class methods take `(opts = {})` and read `opts`.
26
+
27
+ ## How to call
28
+
29
+ ```ruby
30
+ PWN::AI::Agent::Solve.help
31
+ PWN::AI::Agent::Solve.help(opts)
32
+ ```
33
+
34
+ ## Public methods
35
+
36
+ - `authors`
37
+ - `help`
38
+
39
+ ## Source
40
+
41
+ `pwn/ai/agent/solve.rb`
42
+
43
+ ## Verification
44
+
45
+ `PWN::AI::Agent::Solve.respond_to?(:authors)` after the
46
+ module is loaded. Read the source for parameter names.
@@ -43,6 +43,7 @@ PWN::AI::Agent::Swarm.personas(opts)
43
43
  - `ask`
44
44
  - `debate`
45
45
  - `broadcast`
46
+ - `solve`
46
47
  - `map_targets`
47
48
  - `fact_record`
48
49
  - `facts_prompt`
@@ -51,6 +51,9 @@ module PWN
51
51
  entry = Registry.lookup(name: name) || Registry.lookup(name: repair_name(name: name))
52
52
  return JSON.generate(error: "unknown tool: #{name}") unless entry
53
53
 
54
+ solve_tools = Thread.current[:pwn_solve_tools]
55
+ return JSON.generate(success: false, error: 'Tool denied by solve role capability boundary') if solve_tools && !solve_tools.include?(entry.name.to_s)
56
+
54
57
  args = parse_args(raw: raw, entry: entry)
55
58
  args = alias_known_keys(args: args, entry: entry)
56
59
  schema = entry.schema[:parameters] || entry.schema['parameters'] || { type: 'object' }
@@ -1704,6 +1704,8 @@ module PWN
1704
1704
  # export_finetune can later teach the LoRA to NOT need it.
1705
1705
  @escalate_warned = false
1706
1706
  private_class_method def self.escalate(opts = {})
1707
+ return if Thread.current[:pwn_solve_tools]
1708
+
1707
1709
  request = opts[:request]
1708
1710
  turn_fails = opts[:turn_fails]
1709
1711
  persona = agent_flag(key: :escalation_persona)
@@ -2058,7 +2060,7 @@ module PWN
2058
2060
  )
2059
2061
 
2060
2062
  runtime = Thread.current[:pwn_request_runtime]
2061
- if runtime.respond_to?(:call)
2063
+ if runtime.respond_to?(:call) && !Thread.current[:pwn_solve_tools]
2062
2064
  return runtime.call(messages: messages, tools: tools) do |prepared, route|
2063
2065
  invoke_provider_chat(engine: (route || {})[:provider] || active_engine, messages: prepared, tools: tools, model: (route || {})[:model], temp: (route || {})[:temperature])
2064
2066
  end
@@ -3160,6 +3162,16 @@ module PWN
3160
3162
  end
3161
3163
 
3162
3164
  # Commit the returned tool before unwinding, without stale recovery work.
3165
+ # Child handoffs are proposals, never a user completion. Solve owns
3166
+ # the original objective and the final artifact/evidence gate.
3167
+ private_class_method def self.solve_handoff?(opts = {})
3168
+ return false unless opts[:solve_handoff] && Thread.current[:pwn_solve_tools] && Array(opts[:calls]).empty?
3169
+
3170
+ JSON.parse(opts[:text]).is_a?(Hash)
3171
+ rescue JSON::ParserError
3172
+ false
3173
+ end
3174
+
3163
3175
  private_class_method def self.steering_tool_checkpoint!(opts = {})
3164
3176
  opts[:steering]&.checkpoint(messages: opts[:messages], phase: :tool) do
3165
3177
  tc = opts[:tool_call]
@@ -3348,7 +3360,7 @@ module PWN
3348
3360
  trivia = world_knowledge?(request: request)
3349
3361
  catalog = catalog_lookup?(request: request)
3350
3362
  browse = request_need(request: request) == :browse
3351
- skip_compass = trivia || catalog || no_tools || browse
3363
+ skip_compass = trivia || catalog || no_tools || browse || opts[:solve_handoff]
3352
3364
  # Trivia / catalog / browse do not get an implement-shaped
3353
3365
  # English compass. Inventing "apply code/host changes" there
3354
3366
  # keeps the model on a Navigate task after the page already loaded.
@@ -3372,7 +3384,7 @@ module PWN
3372
3384
  Thread.current[:pwn_plan_predicted] = nil
3373
3385
  cal_state = calibration_state
3374
3386
  force_plan = cal_state[:force_plan]
3375
- skip_plan = %i[howto recall greeting].include?(intent) || trivia || catalog || no_tools || browse
3387
+ skip_plan = %i[howto recall greeting].include?(intent) || skip_compass
3376
3388
  did_plan = false
3377
3389
  if !skip_plan && (force_plan || agent_flag(key: :plan_first, default: local) || budget_exhaustion_hot?) && !Array(tools).empty?
3378
3390
  predicted = plan_first(messages: messages, request: request, ts_state: ts_state)
@@ -3523,6 +3535,8 @@ module PWN
3523
3535
 
3524
3536
  messages << msg
3525
3537
 
3538
+ return text if solve_handoff?(opts.merge(text: text, calls: calls))
3539
+
3526
3540
  if calls.empty?
3527
3541
  # P28 — refuse polite mid-goal handoffs so multi-step tasks stay autonomous.
3528
3542
  if incomplete_final?(text: text, last_iter: false)
@@ -3650,7 +3664,7 @@ module PWN
3650
3664
  # P17 — never fork counterfactual when budget fingerprints dominate:
3651
3665
  # CF is another mini agent loop and is the #1 amplifier of
3652
3666
  # iteration-budget exhaustion on this host.
3653
- if count >= thresh && !retryable_execution_failure?(raw: raw) && !escalated && defined?(Curriculum) && !budget_exhaustion_hot?
3667
+ if count >= thresh && !retryable_execution_failure?(raw: raw) && !escalated && defined?(Curriculum) && !budget_exhaustion_hot? && !opts[:solve_handoff]
3654
3668
  cf = (turn_fails["cf:#{fkey}"] += 1) == 1 ? Curriculum.counterfactual(request: request, name: name, args: args, error: tele[:err] || raw[0, 200], hint: hint) : nil
3655
3669
  hint = "#{hint}\n[pwn-ai/counterfactual] branch #{cf[:branch]} (score=#{cf[:score].round(2)}): #{cf[:content]}" if cf
3656
3670
  end
@@ -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