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 +4 -4
- data/README.md +5 -5
- data/documentation/Swarm.md +113 -0
- data/documentation/pwn-ai-Agent.md +1 -1
- 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/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/plugins/repl/ai.rb +6 -1
- data/lib/pwn/plugins/repl/ai_console.rb +35 -9
- 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/plugins/repl/ai_console_commands_spec.rb +37 -1
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +55 -25
- data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +67 -7
- data/third_party/pwn_rdoc.jsonl +16 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 20f94659af48e0dba30534ed36cfaf8942497dcc5851e346a44ef40c51abb130
|
|
4
|
+
data.tar.gz: 8f91abcce730d25f6721d89aa6389090aa572b40ffe7449cd419a874d15d1932
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
[](https://0day.myspreadshop.com/home+%26+living+stickers+%26+magnets)
|
|
282
282
|
|
|
283
|
-
[](https://0day.myspreadshop.com/home+%26+living+mugs+%26+drinkwear)
|
|
284
284
|
|
|
285
|
-
[](https://0day.myspreadshop.com/home+%26+living+other)
|
|
286
286
|
|
|
287
|
-
[](https://0day.myspreadshop.com/0dayinc-A5c3e498cf937643162a01b5f?productType=803&sellable=1naaRYxY1zhOoQlv7Mdg-803-34&appearance=2&size=29)
|
|
288
288
|
|
|
289
|
-
[](https://0day.myspreadshop.com/blackfingerprint-A5c3e49db1cbf3a0b9596b4d0?productType=111&sellable=ekXXmGonOyclynglNE87-111-22&appearance=5)
|
data/documentation/Swarm.md
CHANGED
|
@@ -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
|
|
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.
|
|
@@ -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' }
|
data/lib/pwn/ai/agent/loop.rb
CHANGED
|
@@ -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) ||
|
|
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
|