pwn 0.5.784 → 0.5.787
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/.ruby-version +1 -1
- data/Gemfile +6 -6
- data/README.md +37 -0
- data/bin/pwn +2 -1
- data/bin/pwn_web_cache_deception +35 -7
- data/build_gem.sh +1 -1
- data/documentation/AI-Integration.md +7 -5
- data/documentation/Agent-Tool-Registry.md +2 -2
- data/documentation/Backup-Retention.md +69 -15
- data/documentation/Configuration.md +10 -6
- data/documentation/Contributing.md +1 -1
- data/documentation/Cron.md +2 -2
- data/documentation/Diagrams.md +2 -2
- data/documentation/General-PWN-Usage.md +4 -3
- data/documentation/Harness-Evaluation.md +1 -1
- data/documentation/Home.md +6 -5
- data/documentation/How-PWN-Works.md +6 -5
- data/documentation/Installation.md +12 -9
- data/documentation/Mission-Cancellation.md +134 -0
- data/documentation/Operability.md +18 -0
- data/documentation/Persistence.md +7 -5
- data/documentation/Plugin-Degradation.md +14 -0
- data/documentation/Plugins.md +3 -2
- data/documentation/Policy-Benchmark.md +32 -33
- data/documentation/Runtime-Source-Protection.md +162 -0
- data/documentation/Transparent-Browser.md +11 -0
- data/documentation/Troubleshooting.md +3 -2
- data/documentation/What-is-PWN.md +2 -2
- data/documentation/YubiKey-Vaults.md +185 -0
- data/documentation/YubiKey.md +218 -0
- data/documentation/diagrams/cron-scheduling.svg +130 -130
- data/documentation/diagrams/dot/cron-scheduling.dot +3 -3
- data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
- data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
- data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +1 -1
- data/documentation/diagrams/dot/reinforcement-learning.dot +2 -2
- data/documentation/diagrams/persistence-filesystem.svg +172 -142
- data/documentation/diagrams/plugin-ecosystem.svg +64 -64
- data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +1 -1
- data/documentation/diagrams/reinforcement-learning.svg +89 -89
- data/documentation/pwn-ai-Agent.md +10 -4
- data/etc/default_cron/session_cleanup.rb +43 -27
- data/etc/default_skills/pwn/ai/agent/mission_namespace/SKILL.md +21 -0
- data/etc/default_skills/pwn/ai/agent/tools/browser_act/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +27 -26
- data/etc/default_skills/pwn/ai/agent/tools/recon_run/SKILL.md +25 -15
- data/etc/default_skills/pwn/ai/agent/tools/workspace_tool/SKILL.md +24 -0
- data/etc/default_skills/pwn/ai/server_tools/SKILL.md +25 -0
- data/etc/default_skills/pwn/backups/SKILL.md +54 -0
- data/etc/default_skills/pwn/plugins/recon/SKILL.md +12 -1
- data/etc/default_skills/pwn/plugins/yubikey/SKILL.md +157 -0
- data/etc/default_skills/pwn/plugins/yubikey/fido/SKILL.md +189 -0
- data/etc/default_skills/pwn/plugins/yubikey/hsmauth/SKILL.md +83 -0
- data/etc/default_skills/pwn/plugins/yubikey/management/SKILL.md +80 -0
- data/etc/default_skills/pwn/plugins/yubikey/oath/SKILL.md +115 -0
- data/etc/default_skills/pwn/plugins/yubikey/openpgp/SKILL.md +144 -0
- data/etc/default_skills/pwn/plugins/yubikey/otp/SKILL.md +97 -0
- data/etc/default_skills/pwn/plugins/yubikey/piv/SKILL.md +110 -0
- data/etc/default_skills/pwn/plugins/yubikey/protocol/SKILL.md +21 -0
- data/etc/default_skills/pwn/plugins/yubikey/security_domain/SKILL.md +75 -0
- data/etc/default_skills/pwn/plugins/yubikey/security_domain/scp/SKILL.md +46 -0
- data/etc/default_skills/pwn/plugins/yubikey/transport/SKILL.md +98 -0
- data/etc/default_skills/pwn/redaction/SKILL.md +13 -1
- data/lib/pwn/ai/agent/dispatch.rb +26 -15
- data/lib/pwn/ai/agent/learning.rb +14 -18
- data/lib/pwn/ai/agent/loop.rb +17 -1
- data/lib/pwn/ai/agent/mission_namespace.rb +331 -0
- data/lib/pwn/ai/agent/policy_evaluation.rb +334 -36
- data/lib/pwn/ai/agent/prompt_builder.rb +13 -2
- data/lib/pwn/ai/agent/request_runtime.rb +122 -18
- data/lib/pwn/ai/agent/reward.rb +5 -3
- data/lib/pwn/ai/agent/skill_consolidation.rb +3 -2
- data/lib/pwn/ai/agent/skill_review.rb +9 -5
- data/lib/pwn/ai/agent/swarm.rb +4 -0
- data/lib/pwn/ai/agent/tools/browser_act.rb +32 -0
- data/lib/pwn/ai/agent/tools/loot_query.rb +11 -1
- data/lib/pwn/ai/agent/tools/recon_run.rb +11 -1
- data/lib/pwn/ai/agent/tools/workspace_tool.rb +29 -0
- data/lib/pwn/ai/agent/turn_finalizer.rb +6 -2
- data/lib/pwn/ai/agent.rb +1 -0
- data/lib/pwn/ai/anthropic.rb +8 -33
- data/lib/pwn/ai/cli.rb +34 -5
- data/lib/pwn/ai/gemini.rb +1 -0
- data/lib/pwn/ai/grok.rb +39 -28
- data/lib/pwn/ai/ollama.rb +4 -2
- data/lib/pwn/ai/open_ai.rb +11 -32
- data/lib/pwn/ai/open_web_ui.rb +62 -4
- data/lib/pwn/ai/router.rb +8 -1
- data/lib/pwn/ai/server_tools.rb +231 -0
- data/lib/pwn/ai.rb +1 -0
- data/lib/pwn/backups.rb +349 -0
- data/lib/pwn/config.rb +70 -39
- data/lib/pwn/migrate.rb +85 -280
- data/lib/pwn/module_skills.rb +7 -0
- data/lib/pwn/plugins/ai_sandbox.rb +538 -1
- data/lib/pwn/plugins/authentication_helper.rb +74 -4
- data/lib/pwn/plugins/basic_auth.rb +2 -1
- data/lib/pwn/plugins/google_workspace.rb +4 -21
- data/lib/pwn/plugins/jobs.rb +1 -1
- data/lib/pwn/plugins/log.rb +3 -1
- data/lib/pwn/plugins/preflight_checker.rb +34 -9
- data/lib/pwn/plugins/recon.rb +4 -1
- data/lib/pwn/plugins/repl/ai.rb +293 -26
- data/lib/pwn/plugins/repl/ai_console.rb +268 -88
- data/lib/pwn/plugins/repl/ai_swarm.rb +177 -58
- data/lib/pwn/plugins/repl/mesh.rb +14 -19
- data/lib/pwn/plugins/repl/vault.rb +4 -15
- data/lib/pwn/plugins/repl.rb +9 -216
- data/lib/pwn/plugins/sandbox/driver.rb +4 -0
- data/lib/pwn/plugins/transparent_browser.rb +215 -0
- data/lib/pwn/plugins/vault.rb +412 -135
- data/lib/pwn/plugins/yubikey/fido.rb +1288 -0
- data/lib/pwn/plugins/yubikey/hsmauth.rb +439 -0
- data/lib/pwn/plugins/yubikey/management.rb +686 -0
- data/lib/pwn/plugins/yubikey/oath.rb +766 -0
- data/lib/pwn/plugins/yubikey/openpgp.rb +1063 -0
- data/lib/pwn/plugins/yubikey/otp.rb +897 -0
- data/lib/pwn/plugins/yubikey/piv.rb +1315 -0
- data/lib/pwn/plugins/yubikey/protocol.rb +341 -0
- data/lib/pwn/plugins/yubikey/security_domain/scp.rb +482 -0
- data/lib/pwn/plugins/yubikey/security_domain.rb +464 -0
- data/lib/pwn/plugins/yubikey/transport.rb +1017 -0
- data/lib/pwn/plugins/yubikey.rb +844 -0
- data/lib/pwn/plugins.rb +1 -0
- data/lib/pwn/redaction.rb +69 -12
- data/lib/pwn/session_trace.rb +4 -0
- data/lib/pwn/setup.rb +114 -24
- data/lib/pwn/version.rb +1 -1
- data/lib/pwn/www/app_cobalt_io.rb +16 -5
- data/lib/pwn/www/bug_crowd.rb +16 -5
- data/lib/pwn/www/coinbase_pro.rb +16 -5
- data/lib/pwn/www/github.rb +30 -14
- data/lib/pwn/www/paypal.rb +15 -4
- data/lib/pwn/www/synack.rb +16 -5
- data/lib/pwn/www/twitter.rb +17 -6
- data/lib/pwn.rb +1 -0
- data/spec/documentation/installation_md_spec.rb +1 -1
- data/spec/fixtures/yubikey/apdu_frames.json +162 -0
- data/spec/integration/authentication_helper_oath_spec.rb +117 -0
- data/spec/integration/controller_spill_spec.rb +48 -0
- data/spec/integration/harness_process_spec.rb +97 -0
- data/spec/integration/mission_cancel_spec.rb +79 -0
- data/spec/integration/mission_curses_pty_spec.rb +44 -0
- data/spec/integration/readme_security_spec.rb +47 -0
- data/spec/integration/web_cache_deception_yubikey_spec.rb +159 -0
- data/spec/lib/pwn/ai/agent/dispatch_spec.rb +6 -3
- data/spec/lib/pwn/ai/agent/dispatch_vault_safety_spec.rb +86 -0
- data/spec/lib/pwn/ai/agent/injection_guard_spec.rb +2 -0
- data/spec/lib/pwn/ai/agent/learning_spec.rb +4 -4
- data/spec/lib/pwn/ai/agent/loop_artifacts_spec.rb +5 -0
- data/spec/lib/pwn/ai/agent/loop_spec.rb +104 -29
- data/spec/lib/pwn/ai/agent/mission_namespace_spec.rb +138 -0
- data/spec/lib/pwn/ai/agent/policy_evaluation_spec.rb +10 -23
- data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +48 -0
- data/spec/lib/pwn/ai/agent/shell_search_effect_spec.rb +44 -0
- data/spec/lib/pwn/ai/agent/solve_spec.rb +6 -4
- data/spec/lib/pwn/ai/agent/tools/browser_act_spec.rb +44 -0
- data/spec/lib/pwn/ai/agent/tools/loot_query_yubikey_spec.rb +55 -0
- data/spec/lib/pwn/ai/agent/tools/recon_run_yubikey_spec.rb +43 -0
- data/spec/lib/pwn/ai/agent/tools/workspace_tool_spec.rb +20 -0
- data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +7 -0
- data/spec/lib/pwn/ai/anthropic_vault_enrollment_spec.rb +37 -0
- data/spec/lib/pwn/ai/cli_spec.rb +8 -16
- data/spec/lib/pwn/ai/oauth_vault_persistence_spec.rb +195 -0
- data/spec/lib/pwn/ai/open_ai_spec.rb +2 -2
- data/spec/lib/pwn/ai/open_web_ui_workspace_spec.rb +36 -0
- data/spec/lib/pwn/ai/router_spec.rb +1 -1
- data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
- data/spec/lib/pwn/backups_spec.rb +129 -0
- data/spec/lib/pwn/config_redaction_yubikey_spec.rb +28 -0
- data/spec/lib/pwn/config_yubikey_spec.rb +92 -0
- data/spec/lib/pwn/migrate_spec.rb +29 -81
- data/spec/lib/pwn/migrate_yubikey_spec.rb +128 -0
- data/spec/lib/pwn/module_skills_spec.rb +8 -0
- data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +230 -0
- data/spec/lib/pwn/plugins/authentication_helper_native_yubikey_spec.rb +40 -0
- data/spec/lib/pwn/plugins/authentication_helper_yubikey_spec.rb +90 -0
- data/spec/lib/pwn/plugins/capability_broker/daemon_spec.rb +1 -1
- data/spec/lib/pwn/plugins/google_workspace_spec.rb +17 -35
- data/spec/lib/pwn/plugins/log_yubikey_spec.rb +47 -0
- data/spec/lib/pwn/plugins/preflight_checker_native_yubikey_spec.rb +19 -0
- data/spec/lib/pwn/plugins/preflight_checker_yubikey_spec.rb +55 -0
- data/spec/lib/pwn/plugins/recon_yubikey_spec.rb +43 -0
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +110 -75
- data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
- data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +19 -16
- data/spec/lib/pwn/plugins/repl/ai_vault_edit_yubikey_spec.rb +103 -0
- data/spec/lib/pwn/plugins/repl/ai_yubikey_spec.rb +48 -0
- data/spec/lib/pwn/plugins/repl/mesh_vault_persistence_spec.rb +149 -0
- data/spec/lib/pwn/plugins/repl/vault_yaml_serialization_spec.rb +11 -0
- data/spec/lib/pwn/plugins/repl/vault_yubikey_spec.rb +39 -0
- data/spec/lib/pwn/plugins/repl_mesh_layout_spec.rb +2 -0
- data/spec/lib/pwn/plugins/repl_spec.rb +56 -5
- data/spec/lib/pwn/plugins/transparent_browser_jev_spec.rb +46 -0
- data/spec/lib/pwn/plugins/vault_native_yubikey_spec.rb +29 -0
- data/spec/lib/pwn/plugins/vault_publication_safety_spec.rb +258 -0
- data/spec/lib/pwn/plugins/vault_store_safety_spec.rb +241 -0
- data/spec/lib/pwn/plugins/vault_yubikey_safety_spec.rb +168 -0
- data/spec/lib/pwn/plugins/vault_yubikey_spec.rb +128 -0
- data/spec/lib/pwn/plugins/vault_yubikey_vector_spec.rb +45 -0
- data/spec/lib/pwn/plugins/yubikey/fido_spec.rb +854 -0
- data/spec/lib/pwn/plugins/yubikey/hsmauth_spec.rb +235 -0
- data/spec/lib/pwn/plugins/yubikey/management_spec.rb +277 -0
- data/spec/lib/pwn/plugins/yubikey/oath_spec.rb +349 -0
- data/spec/lib/pwn/plugins/yubikey/openpgp_spec.rb +810 -0
- data/spec/lib/pwn/plugins/yubikey/otp_spec.rb +337 -0
- data/spec/lib/pwn/plugins/yubikey/piv_spec.rb +646 -0
- data/spec/lib/pwn/plugins/yubikey/protocol_secure_spec.rb +28 -0
- data/spec/lib/pwn/plugins/yubikey/protocol_spec.rb +149 -0
- data/spec/lib/pwn/plugins/yubikey/protocol_vectors_spec.rb +18 -0
- data/spec/lib/pwn/plugins/yubikey/security_domain/scp_spec.rb +256 -0
- data/spec/lib/pwn/plugins/yubikey/security_domain/support.rb +153 -0
- data/spec/lib/pwn/plugins/yubikey/security_domain_spec.rb +296 -0
- data/spec/lib/pwn/plugins/yubikey/transport_spec.rb +760 -0
- data/spec/lib/pwn/plugins/yubikey_security_spec.rb +260 -0
- data/spec/lib/pwn/plugins/yubikey_spec.rb +209 -0
- data/spec/lib/pwn/plugins/yubikey_terminal_spec.rb +147 -0
- data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
- data/spec/lib/pwn/session_cleanup_default_spec.rb +7 -5
- data/spec/lib/pwn/session_cleanup_payload_spec.rb +11 -11
- data/spec/lib/pwn/session_cleanup_recovery_spec.rb +34 -20
- data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
- data/spec/lib/pwn/setup_vault_yubikey_spec.rb +61 -0
- data/spec/lib/pwn/setup_yubikey_spec.rb +40 -0
- data/spec/lib/pwn/www/login_yubikey_spec.rb +273 -0
- data/spec/spec_helper.rb +15 -0
- data/spec/support/harness/cli_faults.rb +13 -9
- data/spec/support/harness/controller_spill.rb +26 -0
- data/spec/support/harness/controller_state.rb +54 -0
- data/spec/support/harness/evaluate.rb +12 -1
- data/spec/support/harness/experiments.rb +1 -1
- data/spec/support/harness/fake_provider.rb +22 -6
- data/spec/support/harness/fault_preload.rb +3 -1
- data/spec/support/harness/mission_console.rb +54 -0
- data/spec/support/harness/mission_curses_pty.rb +41 -0
- data/spec/support/harness/mission_dispatch.rb +30 -0
- data/spec/support/harness/mission_fixture_preload.rb +24 -0
- data/spec/support/harness/mission_job_lifetime.rb +25 -0
- data/spec/support/harness/mission_legacy.rb +25 -0
- data/spec/support/harness/mission_normal_exit.rb +34 -0
- data/spec/support/harness/mission_swarm.rb +23 -0
- data/spec/support/harness/mission_unsupported.rb +29 -0
- data/spec/support/harness/package_acceptance.rb +33 -5
- data/spec/support/harness/session_acceptance.rb +4 -4
- data/spec/support/harness/swarm_acceptance.rb +1 -1
- data/spec/support/harness/swarm_control.rb +44 -0
- data/spec/support/mission_namespace.rb +14 -0
- data/spec/support/mission_pipe_queue.rb +33 -0
- data/third_party/pwn_rdoc.jsonl +605 -19
- metadata +129 -16
- data/scripts/benchmark_policy.rb +0 -376
- data/scripts/build_gate.rb +0 -58
- data/spec/integration/build_gate_spec.rb +0 -73
|
@@ -49,6 +49,29 @@ module PWN
|
|
|
49
49
|
end
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
+
# Parent-side typed control pipe for one namespace-owned job.
|
|
53
|
+
class RemoteControl
|
|
54
|
+
attr_accessor :observed_pause
|
|
55
|
+
|
|
56
|
+
def initialize(io)
|
|
57
|
+
@io = io
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def submit(text)
|
|
61
|
+
send_control('submit', text)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def pause(value)
|
|
65
|
+
send_control('pause', value)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def send_control(action, value)
|
|
71
|
+
@io.write("#{JSON.generate([action, value])}\n")
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
52
75
|
# Session-scoped swarm jobs. Workers never draw the terminal.
|
|
53
76
|
class Controller # rubocop:disable Metrics/ClassLength -- one session-scoped swarm command surface
|
|
54
77
|
MAX_JOBS = 8
|
|
@@ -227,23 +250,14 @@ module PWN
|
|
|
227
250
|
request = tokens.join(' ')
|
|
228
251
|
return { ok: false, error: 'usage: dm NAME REQUEST' } if name.to_s.empty? || request.empty?
|
|
229
252
|
|
|
230
|
-
start_job(agent: name, command: 'dm', opts: opts
|
|
231
|
-
PWN::AI::Agent::Swarm.ask(name: name, request: request, swarm_id: ensure_swarm, steering: job[:control], usage_observer: opts[:usage_observer], on_tool: tool_callback(job, opts))
|
|
232
|
-
end
|
|
253
|
+
start_job(agent: name, command: 'dm', opts: opts, task: { name: name, request: request })
|
|
233
254
|
end
|
|
234
255
|
|
|
235
256
|
def solve(tokens, opts)
|
|
236
257
|
request = opts.fetch(:solve_request, tokens.join(' '))
|
|
237
258
|
return { ok: false, error: 'usage: mission REQUEST' } if request.strip.empty?
|
|
238
259
|
|
|
239
|
-
start_job(agent: 'team', command: 'mission', opts: opts
|
|
240
|
-
PWN::AI::Agent::Swarm.solve(
|
|
241
|
-
request: request, swarm_id: ensure_swarm, steering: job[:control],
|
|
242
|
-
worktree: opts.fetch(:worktree, false), rounds: opts.fetch(:rounds, 3), verification_timeout: opts.fetch(:verification_timeout, 60), role_timeout: opts.fetch(:role_timeout, 300),
|
|
243
|
-
workspace: opts[:workspace], usage_observer: opts[:usage_observer], on_tool: tool_callback(job, opts),
|
|
244
|
-
on_state: ->(state) { @mutex.synchronize { job[:team] = state } }
|
|
245
|
-
)
|
|
246
|
-
end
|
|
260
|
+
start_job(agent: 'team', command: 'mission', opts: opts, task: { request: request })
|
|
247
261
|
end
|
|
248
262
|
|
|
249
263
|
def debate(tokens, opts)
|
|
@@ -254,18 +268,7 @@ module PWN
|
|
|
254
268
|
return { ok: false, error: 'usage: debate NAME,NAME TOPIC [--rounds N]' } if names.length < 2 || topic.empty?
|
|
255
269
|
return { ok: false, error: 'rounds must be between 1 and 10' } unless rounds.between?(1, 10)
|
|
256
270
|
|
|
257
|
-
start_job(agent: names.join(','), command: 'debate', opts: opts
|
|
258
|
-
transcript = []
|
|
259
|
-
rounds.times do
|
|
260
|
-
names.each do |name|
|
|
261
|
-
request = transcript.empty? ? topic : "#{topic}\n\nPrevious speaker: #{transcript.last}\nRespond, critique, or advance the objective."
|
|
262
|
-
job[:control].check_stop
|
|
263
|
-
result = PWN::AI::Agent::Swarm.ask(name: name, request: request, swarm_id: @swarm_id, steering: job[:control], usage_observer: opts[:usage_observer], on_tool: tool_callback(job, opts))
|
|
264
|
-
transcript << "#{name}: #{result[:reply]}"
|
|
265
|
-
end
|
|
266
|
-
end
|
|
267
|
-
{ reply: transcript.join("\n") }
|
|
268
|
-
end
|
|
271
|
+
start_job(agent: names.join(','), command: 'debate', opts: opts, task: { names: names, topic: topic, rounds: rounds })
|
|
269
272
|
end
|
|
270
273
|
|
|
271
274
|
def broadcast(tokens, opts)
|
|
@@ -274,14 +277,7 @@ module PWN
|
|
|
274
277
|
names = flags['names']&.split(',') || agents
|
|
275
278
|
return { ok: false, error: 'usage: broadcast REQUEST [--names a,b]' } if request.empty? || names.empty?
|
|
276
279
|
|
|
277
|
-
start_job(agent: names.join(','), command: 'broadcast', opts: opts
|
|
278
|
-
replies = names.map do |name|
|
|
279
|
-
job[:control].check_stop
|
|
280
|
-
result = PWN::AI::Agent::Swarm.ask(name: name, request: request, swarm_id: ensure_swarm, steering: job[:control], usage_observer: opts[:usage_observer], on_tool: tool_callback(job, opts))
|
|
281
|
-
"#{name}: #{result[:reply]}"
|
|
282
|
-
end
|
|
283
|
-
{ reply: replies.join("\n") }
|
|
284
|
-
end
|
|
280
|
+
start_job(agent: names.join(','), command: 'broadcast', opts: opts, task: { names: names, request: request })
|
|
285
281
|
end
|
|
286
282
|
|
|
287
283
|
def steer(tokens, opts)
|
|
@@ -315,7 +311,9 @@ module PWN
|
|
|
315
311
|
|
|
316
312
|
return { ok: false, error: 'job is no longer active' } unless update_active(job, 'cancelling')
|
|
317
313
|
|
|
318
|
-
job[:
|
|
314
|
+
job[:mission].close
|
|
315
|
+
job[:worker]&.join
|
|
316
|
+
update(job, 'cancelled', error: 'Mission cancelled; all owned processes reaped.')
|
|
319
317
|
{ ok: true, job_id: job[:id] }
|
|
320
318
|
end
|
|
321
319
|
|
|
@@ -331,7 +329,92 @@ module PWN
|
|
|
331
329
|
{ ok: true, messages: PWN::AI::Agent::Swarm.bus_tail(swarm_id: @swarm_id, limit: (limit || 8).to_i) }
|
|
332
330
|
end
|
|
333
331
|
|
|
334
|
-
def
|
|
332
|
+
private_class_method def self.mission_entry(opts = {})
|
|
333
|
+
data = opts[:descriptor]
|
|
334
|
+
controller = new(session_id: data[:session_id])
|
|
335
|
+
controller.instance_variable_set(:@swarm_id, data[:swarm_id])
|
|
336
|
+
controller.send(:mission_child, data[:job], data[:task], data[:options], PWN::AI::Agent::MissionNamespace.io(4), PWN::AI::Agent::MissionNamespace.io(5))
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def mission_child(job, task, opts, writer, commands)
|
|
340
|
+
id = job[:id]
|
|
341
|
+
command = job[:command]
|
|
342
|
+
@child_events = AIConsole::MissionEvents.new(writer, id)
|
|
343
|
+
output = AIConsole::EventIO.new(@child_events)
|
|
344
|
+
$stdout = $stderr = output
|
|
345
|
+
opts[:on_event] = ->(type, text) { @child_events << [:event, [type, text]] }
|
|
346
|
+
opts[:usage_observer] = ->(usage) { @child_events << [:usage, usage] }
|
|
347
|
+
control_class = command == 'mission' ? MissionControl : JobControl
|
|
348
|
+
job[:control] = control_class.new(input: StringIO.new, output: StringIO.new)
|
|
349
|
+
Thread.current[:pwn_steering_input] = job[:control]
|
|
350
|
+
commands_thread = Thread.new do
|
|
351
|
+
commands.each_line do |line|
|
|
352
|
+
action, value = JSON.parse(line)
|
|
353
|
+
job[:control].submit(value) if action == 'submit'
|
|
354
|
+
job[:control].pause(value) if action == 'pause' && command == 'mission'
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
snapshot_thread = Thread.new do
|
|
358
|
+
loop do
|
|
359
|
+
@child_events << [:snapshot, { team: job[:team], paused: command == 'mission' && job[:control].observed_pause }]
|
|
360
|
+
sleep 0.05
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
begin
|
|
364
|
+
result = mission_operation(command, task, job, opts)
|
|
365
|
+
job[:control].check_stop unless command == 'mission'
|
|
366
|
+
state = command == 'mission' ? result[:status] : 'completed'
|
|
367
|
+
@child_events << [:complete, { state: state, result: summarize(result), team: command == 'mission' ? (job[:team] || {}).merge(result) : nil }]
|
|
368
|
+
rescue JobControl::Stopped, PWN::AI::Agent::Solve::Control::Stopped => e
|
|
369
|
+
@child_events << [:complete, { state: 'cancelled', error: e.message }]
|
|
370
|
+
rescue StandardError => e
|
|
371
|
+
@child_events << [:complete, { state: 'failed', error: "#{e.class}: #{e.message}" }]
|
|
372
|
+
ensure
|
|
373
|
+
commands_thread.kill.join
|
|
374
|
+
snapshot_thread.kill.join
|
|
375
|
+
output.flush
|
|
376
|
+
writer.close
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def mission_operation(command, task, job, opts)
|
|
381
|
+
name = task[:name]
|
|
382
|
+
request = task[:request]
|
|
383
|
+
names = task[:names]
|
|
384
|
+
topic = task[:topic]
|
|
385
|
+
rounds = task[:rounds]
|
|
386
|
+
case command
|
|
387
|
+
when 'dm'
|
|
388
|
+
PWN::AI::Agent::Swarm.ask(name: name, request: request, swarm_id: ensure_swarm, steering: job[:control], usage_observer: opts[:usage_observer], on_tool: tool_callback(job, opts))
|
|
389
|
+
when 'mission'
|
|
390
|
+
PWN::AI::Agent::Swarm.solve(
|
|
391
|
+
request: request, swarm_id: ensure_swarm, steering: job[:control],
|
|
392
|
+
worktree: opts.fetch(:worktree, false), rounds: opts.fetch(:rounds, 3), verification_timeout: opts.fetch(:verification_timeout, 60), role_timeout: opts.fetch(:role_timeout, 300),
|
|
393
|
+
workspace: opts[:workspace], usage_observer: opts[:usage_observer], on_tool: tool_callback(job, opts),
|
|
394
|
+
on_state: ->(state) { @mutex.synchronize { job[:team] = state } }
|
|
395
|
+
)
|
|
396
|
+
when 'debate'
|
|
397
|
+
transcript = []
|
|
398
|
+
rounds.times do
|
|
399
|
+
names.each do |name|
|
|
400
|
+
request = transcript.empty? ? topic : "#{topic}\n\nPrevious speaker: #{transcript.last}\nRespond, critique, or advance the objective."
|
|
401
|
+
job[:control].check_stop
|
|
402
|
+
result = PWN::AI::Agent::Swarm.ask(name: name, request: request, swarm_id: @swarm_id, steering: job[:control], usage_observer: opts[:usage_observer], on_tool: tool_callback(job, opts))
|
|
403
|
+
transcript << "#{name}: #{result[:reply]}"
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
{ reply: transcript.join("\n") }
|
|
407
|
+
when 'broadcast'
|
|
408
|
+
replies = names.map do |name|
|
|
409
|
+
job[:control].check_stop
|
|
410
|
+
result = PWN::AI::Agent::Swarm.ask(name: name, request: request, swarm_id: ensure_swarm, steering: job[:control], usage_observer: opts[:usage_observer], on_tool: tool_callback(job, opts))
|
|
411
|
+
"#{name}: #{result[:reply]}"
|
|
412
|
+
end
|
|
413
|
+
{ reply: replies.join("\n") }
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def start_job(agent:, command:, opts:, task:)
|
|
335
418
|
return { ok: false, error: 'swarm controller is closed' } if @closed
|
|
336
419
|
return { ok: false, error: 'mission requires exclusive workspace ownership; wait or cancel active jobs' } if (command == 'mission' && busy?) || snapshot[:jobs].any? { |row| row[:command] == 'mission' && ACTIVE.include?(row[:state]) }
|
|
337
420
|
|
|
@@ -339,36 +422,71 @@ module PWN
|
|
|
339
422
|
|
|
340
423
|
ensure_swarm
|
|
341
424
|
id = SecureRandom.hex(3)
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
job = { id: id, agent: agent, command: command, state: 'queued' }
|
|
425
|
+
reader, writer = IO.pipe
|
|
426
|
+
commands, sender = IO.pipe
|
|
427
|
+
job = { id: id, agent: agent, command: command, state: 'queued', sender: sender }
|
|
346
428
|
@mutex.synchronize { @jobs[id] = job }
|
|
429
|
+
on_event = opts[:on_event]
|
|
430
|
+
on_usage = opts[:usage_observer]
|
|
431
|
+
job[:mission] = PWN::Plugins::AISandbox::MissionProcess.new(
|
|
432
|
+
descriptor: { operation: :swarm, session_id: @session_id, swarm_id: @swarm_id, job: job.slice(:id, :agent, :command), task: task, options: opts.except(:on_event, :usage_observer) },
|
|
433
|
+
fds: { 4 => writer, 5 => commands }
|
|
434
|
+
)
|
|
435
|
+
writer.close
|
|
436
|
+
commands.close
|
|
437
|
+
job[:control] = RemoteControl.new(sender)
|
|
438
|
+
update(job, 'running')
|
|
347
439
|
job[:worker] = Thread.new do
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
440
|
+
result = nil
|
|
441
|
+
reader.each_line do |line|
|
|
442
|
+
tag, type, value = JSON.parse(line, symbolize_names: true)
|
|
443
|
+
next unless tag == id && job[:state] != 'cancelling'
|
|
444
|
+
|
|
445
|
+
restore_team_maps(value[:team]) if value.is_a?(Hash) && value[:team].is_a?(Hash)
|
|
446
|
+
case type
|
|
447
|
+
when 'complete' then result = value
|
|
448
|
+
when 'event' then on_event&.call(value[0].to_sym, value[1])
|
|
449
|
+
when 'notice' then on_event&.call(:notice, value)
|
|
450
|
+
when 'usage' then on_usage&.call(value)
|
|
451
|
+
when 'snapshot'
|
|
452
|
+
@mutex.synchronize { job[:team] = value[:team] if value[:team] }
|
|
453
|
+
job[:control].observed_pause = value[:paused]
|
|
454
|
+
when 'tool' then update(job, job[:state], last_tool: value)
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
ensure
|
|
458
|
+
job[:mission].close
|
|
459
|
+
reader.close
|
|
460
|
+
sender.close
|
|
461
|
+
unless job[:state] == 'cancelling'
|
|
462
|
+
result ||= { state: 'failed', error: 'mission process exited without a result' }
|
|
463
|
+
@mutex.synchronize { job[:team] = (job[:team] || {}).merge(result[:team]) } if result[:team]
|
|
464
|
+
update(job, result.fetch(:state), **result.slice(:result, :error))
|
|
465
|
+
on_event&.call(result[:error] ? :warning : :assistant, "#{agent} [#{id}] #{command} #{job[:state]}\n#{result[:result] || result[:error]}")
|
|
466
|
+
end
|
|
368
467
|
end
|
|
369
468
|
job[:worker].report_on_exception = false
|
|
370
|
-
ready.pop
|
|
371
469
|
{ ok: true, job_id: id, state: 'queued' }
|
|
470
|
+
rescue StandardError => e
|
|
471
|
+
[reader, writer, commands, sender].compact.each { |io| io.close unless io.closed? }
|
|
472
|
+
job&.dig(:mission)&.close
|
|
473
|
+
update(job, 'failed', error: e.message) if job
|
|
474
|
+
{ ok: false, error: "Mission not started: #{e.message}" }
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
# JSON object member names are strings on wire. Restore data maps whose
|
|
478
|
+
# keys are identifiers or filenames, rather than protocol field names.
|
|
479
|
+
def restore_team_maps(value)
|
|
480
|
+
case value
|
|
481
|
+
when Hash
|
|
482
|
+
value.each do |key, child|
|
|
483
|
+
value[key] = child = child.transform_keys(&:to_s) if %i[roles artifacts dependencies requirements inputs modes].include?(key) && child.is_a?(Hash)
|
|
484
|
+
restore_team_maps(child)
|
|
485
|
+
end
|
|
486
|
+
when Array
|
|
487
|
+
value.each { |child| restore_team_maps(child) }
|
|
488
|
+
end
|
|
489
|
+
value
|
|
372
490
|
end
|
|
373
491
|
|
|
374
492
|
def tool_callback(job, opts)
|
|
@@ -378,6 +496,7 @@ module PWN
|
|
|
378
496
|
return
|
|
379
497
|
end
|
|
380
498
|
update(job, job[:state], last_tool: name)
|
|
499
|
+
@child_events << [:tool, name] if @child_events
|
|
381
500
|
opts[:on_event]&.call(:tool, "#{job[:agent]} [#{job[:id]}] #{name}\n#{args.inspect}")
|
|
382
501
|
opts[:on_event]&.call(:result, result.to_s)
|
|
383
502
|
end
|
|
@@ -84,6 +84,7 @@ module PWN
|
|
|
84
84
|
|
|
85
85
|
# Init ncurses UI (idempotent) with separate RX (top) and TX (bottom) panes
|
|
86
86
|
Curses.init_screen
|
|
87
|
+
PWN.const_set(:MeshCurses, true)
|
|
87
88
|
Curses.curs_set(0)
|
|
88
89
|
Curses.noecho
|
|
89
90
|
Curses.cbreak
|
|
@@ -340,7 +341,10 @@ class << PWN::Plugins::REPL # rubocop:disable Metrics/ClassLength
|
|
|
340
341
|
top = Integer(opts[:top])
|
|
341
342
|
left = Integer(opts[:left])
|
|
342
343
|
win = PWN.const_defined?(name) ? PWN.const_get(name) : nil
|
|
344
|
+
ready = PWN.const_defined?(:MeshCurses) && PWN::MeshCurses == true
|
|
343
345
|
if win.nil?
|
|
346
|
+
return nil unless ready
|
|
347
|
+
|
|
344
348
|
win = Curses::Window.new(height, width, top, left)
|
|
345
349
|
PWN.const_set(name, win)
|
|
346
350
|
elsif win.respond_to?(:resize) && win.respond_to?(:move)
|
|
@@ -349,6 +353,8 @@ class << PWN::Plugins::REPL # rubocop:disable Metrics/ClassLength
|
|
|
349
353
|
win.erase if win.respond_to?(:erase)
|
|
350
354
|
else
|
|
351
355
|
mesh_drop_win(name: name)
|
|
356
|
+
return nil unless ready
|
|
357
|
+
|
|
352
358
|
win = Curses::Window.new(height, width, top, left)
|
|
353
359
|
PWN.const_set(name, win)
|
|
354
360
|
end
|
|
@@ -403,7 +409,7 @@ class << PWN::Plugins::REPL # rubocop:disable Metrics/ClassLength
|
|
|
403
409
|
conv = layout[:conversation]
|
|
404
410
|
mesh_place_win(name: :MeshRxHeaderWin, height: layout[:header], width: layout[:cols], top: 0, left: 0, scroll: false)
|
|
405
411
|
frame = mesh_place_win(name: :MeshRxFrameWin, height: layout[:body], width: conv, top: body_start, left: 0, scroll: false)
|
|
406
|
-
mesh_box!(win: frame)
|
|
412
|
+
mesh_box!(win: frame) if frame
|
|
407
413
|
mesh_place_win(
|
|
408
414
|
name: :MeshRxBodyWin,
|
|
409
415
|
height: [layout[:body] - 2, 1].max,
|
|
@@ -428,8 +434,10 @@ class << PWN::Plugins::REPL # rubocop:disable Metrics/ClassLength
|
|
|
428
434
|
mesh_drop_win(name: :MeshRcvrWin)
|
|
429
435
|
end
|
|
430
436
|
tx = mesh_place_win(name: :MeshTxWin, height: layout[:tx], width: layout[:cols], top: layout[:tx_top], left: 0, scroll: false)
|
|
431
|
-
|
|
432
|
-
|
|
437
|
+
if tx
|
|
438
|
+
tx.keypad(true) if tx.respond_to?(:keypad)
|
|
439
|
+
tx.timeout = 100 if tx.respond_to?(:timeout=)
|
|
440
|
+
end
|
|
433
441
|
PWN.const_set(:MeshMutex, Mutex.new) unless PWN.const_defined?(:MeshMutex)
|
|
434
442
|
state = PWN.const_defined?(:MeshRxState) ? PWN::MeshRxState : nil
|
|
435
443
|
mesh_set_focus(focus: :conversation) if state.is_a?(Hash) && %i[sndr rcvr].include?(state[:focus]) && !mesh_side_panes?
|
|
@@ -3906,17 +3914,9 @@ class << PWN::Plugins::REPL # rubocop:disable Metrics/ClassLength
|
|
|
3906
3914
|
env_path = File.join(Dir.home, '.pwn', 'pwn.yaml') if env_path.empty?
|
|
3907
3915
|
dec_path = dec_path.to_s.strip
|
|
3908
3916
|
dec_path = "#{env_path}.decryptor" if dec_path.empty?
|
|
3909
|
-
return false unless File.exist?(env_path)
|
|
3917
|
+
return false unless File.exist?(env_path)
|
|
3910
3918
|
|
|
3911
|
-
|
|
3912
|
-
key = decryptor.is_a?(Hash) ? decryptor[:key] : nil
|
|
3913
|
-
iv = decryptor.is_a?(Hash) ? decryptor[:iv] : nil
|
|
3914
|
-
return false if key.to_s.strip.empty? || iv.to_s.strip.empty?
|
|
3915
|
-
|
|
3916
|
-
PWN::Plugins::Vault.decrypt(file: env_path, key: key, iv: iv)
|
|
3917
|
-
begin
|
|
3918
|
-
cfg = YAML.load_file(env_path, symbolize_names: true)
|
|
3919
|
-
cfg = {} unless cfg.is_a?(Hash)
|
|
3919
|
+
PWN::Plugins::Vault.update(file: env_path, decryptor_file: dec_path) do |cfg|
|
|
3920
3920
|
cfg[:plugins] = {} unless cfg[:plugins].is_a?(Hash)
|
|
3921
3921
|
dst = cfg[:plugins][:meshtastic]
|
|
3922
3922
|
dst = {} unless dst.is_a?(Hash)
|
|
@@ -3933,15 +3933,10 @@ class << PWN::Plugins::REPL # rubocop:disable Metrics/ClassLength
|
|
|
3933
3933
|
end
|
|
3934
3934
|
end
|
|
3935
3935
|
cfg[:plugins][:meshtastic] = dst
|
|
3936
|
-
yaml_env = YAML.dump(cfg).gsub(/^(\s*):/, '\1')
|
|
3937
|
-
File.write(env_path, yaml_env)
|
|
3938
|
-
File.chmod(0o600, env_path)
|
|
3939
|
-
ensure
|
|
3940
|
-
PWN::Plugins::Vault.encrypt(file: env_path, key: key, iv: iv)
|
|
3941
3936
|
end
|
|
3942
3937
|
true
|
|
3943
3938
|
rescue StandardError => e
|
|
3944
|
-
mesh_ui_puts(text: "[pwn-mesh] persist skipped: #{e.class}
|
|
3939
|
+
mesh_ui_puts(text: "[pwn-mesh] persist skipped: #{e.class}")
|
|
3945
3940
|
false
|
|
3946
3941
|
end
|
|
3947
3942
|
|
|
@@ -23,14 +23,12 @@ module PWN
|
|
|
23
23
|
# Prefer driver_opts (set by Config.refresh_env); fall back to the
|
|
24
24
|
# canonical sidecar path Config uses: <pwn.yaml>.decryptor
|
|
25
25
|
pwn_dec_path = PWN::Env[:driver_opts][:pwn_dec_path] ||= "#{pwn_env_path}.decryptor"
|
|
26
|
-
unless File.exist?(pwn_dec_path)
|
|
26
|
+
unless PWN::Plugins::Vault.hardware_encrypted?(file: pwn_env_path) || File.exist?(pwn_dec_path)
|
|
27
27
|
puts "ERROR: pwn decryptor file not found: #{pwn_dec_path}"
|
|
28
28
|
return
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
key = decryptor[:key]
|
|
33
|
-
iv = decryptor[:iv]
|
|
31
|
+
access = PWN::Plugins::Vault.access_options(file: pwn_env_path, decryptor_file: pwn_dec_path)
|
|
34
32
|
|
|
35
33
|
# Vault.edit decrypts -> opens editor -> encrypts and only sets
|
|
36
34
|
# Pry.config.refresh_pwn_env = true. PWN::Config.refresh_env (which
|
|
@@ -40,19 +38,10 @@ module PWN
|
|
|
40
38
|
# prompt "Press Enter to Resolve", wait on $stdin.gets, then re-open
|
|
41
39
|
# the editor until the vault loads cleanly.
|
|
42
40
|
loop do
|
|
43
|
-
PWN::Plugins::Vault.edit(
|
|
44
|
-
file: pwn_env_path,
|
|
45
|
-
key: key,
|
|
46
|
-
iv: iv
|
|
47
|
-
)
|
|
41
|
+
PWN::Plugins::Vault.edit(access.merge(file: pwn_env_path))
|
|
48
42
|
|
|
49
43
|
begin
|
|
50
|
-
PWN::Config.refresh_env(
|
|
51
|
-
pwn_env_path: pwn_env_path,
|
|
52
|
-
pwn_dec_path: pwn_dec_path,
|
|
53
|
-
key: key,
|
|
54
|
-
iv: iv
|
|
55
|
-
)
|
|
44
|
+
PWN::Config.refresh_env(access.merge(pwn_env_path: pwn_env_path, pwn_dec_path: pwn_dec_path))
|
|
56
45
|
break
|
|
57
46
|
rescue RuntimeError => e
|
|
58
47
|
# Keep the prior in-memory Env usable if the operator aborts
|
data/lib/pwn/plugins/repl.rb
CHANGED
|
@@ -642,222 +642,7 @@ module PWN
|
|
|
642
642
|
# engines have a working .chat_with_tools and the native loop has had
|
|
643
643
|
# real-API smoke time on each).
|
|
644
644
|
# ----------------------------------------------------------------
|
|
645
|
-
|
|
646
|
-
# We will mutate it to 'nil' at the end of handling so Pry does not eval the natural-language
|
|
647
|
-
# prompt text as Ruby (which was causing noisy exceptions *after* the green AI response print).
|
|
648
|
-
debug = pi.config.pwn_ai_debug
|
|
649
|
-
engine = PWN::Env[:ai][:active].to_s.downcase.to_sym
|
|
650
|
-
response_history = PWN::Env[:ai][engine][:response_history]
|
|
651
|
-
speak_answer = pi.config.pwn_ai_speak
|
|
652
|
-
is_agent = (pi.config.pwn_ai_agent == true)
|
|
653
|
-
|
|
654
|
-
# pwn-ai agent mode: load skills context for autonomous task carrying
|
|
655
|
-
skills_context = ''
|
|
656
|
-
PWN::Skills.each { |n, m| skills_context += "\n--- SKILL #{n} ---\n#{m[:content].to_s[0, 1200]}\n" } if is_agent && PWN.const_defined?(:Skills) && PWN::Skills.is_a?(Hash)
|
|
657
|
-
|
|
658
|
-
memory_context = ''
|
|
659
|
-
memory_context = PWN::Memory.to_context(limit: 25) if is_agent && PWN.const_defined?(:Memory)
|
|
660
|
-
|
|
661
|
-
sess_id = begin
|
|
662
|
-
pi.config.pwn_ai_session_id
|
|
663
|
-
rescue StandardError
|
|
664
|
-
nil
|
|
665
|
-
end
|
|
666
|
-
|
|
667
|
-
# Pre-process for clear CLI execution intent (e.g. "what does `id` return?")
|
|
668
|
-
# This makes the agent actually *run* commands instead of just explaining them.
|
|
669
|
-
curr_req = orig_request.chomp
|
|
670
|
-
if is_agent && sess_id && PWN.const_defined?(:Sessions)
|
|
671
|
-
begin
|
|
672
|
-
PWN::Sessions.append(session_id: sess_id, role: 'user', content: orig_request)
|
|
673
|
-
rescue StandardError
|
|
674
|
-
nil
|
|
675
|
-
end
|
|
676
|
-
end
|
|
677
|
-
if is_agent && request =~ /`([^`]+)`/
|
|
678
|
-
potential = ::Regexp.last_match(1).strip
|
|
679
|
-
# Looks like a shell command (not PWN ruby)
|
|
680
|
-
unless potential =~ /^(PWN::|def |class |require |puts |pp )/
|
|
681
|
-
curr_req = "The user wants the *actual raw output* of this command (do not just describe it): `#{potential}`. " \
|
|
682
|
-
'To fulfill the request accurately, you MUST immediately output ONLY a bash code block with the exact command. ' \
|
|
683
|
-
"Example format: ```bash\n#{potential}\n``` . After the host executes it, you will receive the OBSERVATION with the real output."
|
|
684
|
-
end
|
|
685
|
-
end
|
|
686
|
-
|
|
687
|
-
# Strict system prompt for agent mode (forces tool use over explanation)
|
|
688
|
-
system_role = nil
|
|
689
|
-
if is_agent
|
|
690
|
-
base = PWN::Env[:ai][engine][:system_role_content] || 'You are an ethical hacker.'
|
|
691
|
-
system_role = base + <<~PROMPT
|
|
692
|
-
|
|
693
|
-
You are operating as an autonomous agent inside the PWN REPL driver.
|
|
694
|
-
|
|
695
|
-
PRIMARY RULE FOR CLI AND TOOLS: When the user asks for the output of a command, "what does X return?", "run X", or anything that requires real execution, you MUST use a tool call.#{' '}
|
|
696
|
-
NEVER just explain what a command does or what its output "would be".#{' '}
|
|
697
|
-
To execute anything:
|
|
698
|
-
- Output *exactly and only* a fenced code block.
|
|
699
|
-
- For shell/CLI: ```bash
|
|
700
|
-
<exact command here>
|
|
701
|
-
```
|
|
702
|
-
- For PWN Ruby modules: ```ruby
|
|
703
|
-
PWN::Plugins::NmapIt.port_scan(...)
|
|
704
|
-
```
|
|
705
|
-
The host will execute it (Ruby in full PWN context, bash via shell) and reply with an OBSERVATION containing the real result.#{' '}
|
|
706
|
-
Then continue or give the final answer.
|
|
707
|
-
|
|
708
|
-
Available tools include all PWN::Plugins (NmapIt, TransparentBrowser, etc.), SAST, Reports, and any CLI via bash blocks.
|
|
709
|
-
Skills available this session:#{skills_context}
|
|
710
|
-
#{memory_context}
|
|
711
|
-
|
|
712
|
-
PERSISTENT CAPABILITIES (use via ruby code blocks or direct calls):
|
|
713
|
-
- Memory (cross-session): PWN::Memory.remember(key: :key, value: val, category: :fact|:preference|:lesson)
|
|
714
|
-
PWN::Memory.recall(query: 'foo'), PWN::Memory.forget(key: key)
|
|
715
|
-
- Sessions: current session id = #{sess_id}; PWN::Sessions.append(session_id: '#{sess_id}', role: 'observation', content: obs)
|
|
716
|
-
- Cron: PWN::Cron.create(schedule: '0 * * * *', prompt: 'task here', name: 'foo')
|
|
717
|
-
PWN::Cron.run(id: 'id'); list with PWN::Cron.list
|
|
718
|
-
- Agents/Delegation: PWN::AI::Agent::SAST.analyze(request: ...); PWN::AI::Agent::VulnGen etc.
|
|
719
|
-
For sub-agents use threads or separate eval calls and feed results back as OBS.
|
|
720
|
-
|
|
721
|
-
After receiving an observation, decide the next step or conclude.
|
|
722
|
-
If you output text without a code block, it will be treated as your final answer to the user.
|
|
723
|
-
PROMPT
|
|
724
|
-
end
|
|
725
|
-
|
|
726
|
-
max_turns = is_agent ? 7 : 1
|
|
727
|
-
turn = 0
|
|
728
|
-
last_response = ''
|
|
729
|
-
tool_was_executed_this_turn = false
|
|
730
|
-
|
|
731
|
-
while turn < max_turns
|
|
732
|
-
chat_opts = {
|
|
733
|
-
request: curr_req,
|
|
734
|
-
response_history: response_history,
|
|
735
|
-
speak_answer: speak_answer,
|
|
736
|
-
spinner: false
|
|
737
|
-
}
|
|
738
|
-
chat_opts[:system_role_content] = system_role if system_role
|
|
739
|
-
|
|
740
|
-
case engine
|
|
741
|
-
when :anthropic
|
|
742
|
-
response = PWN::AI::Anthropic.chat(chat_opts)
|
|
743
|
-
when :gemini
|
|
744
|
-
response = PWN::AI::Gemini.chat(chat_opts)
|
|
745
|
-
when :grok
|
|
746
|
-
response = PWN::AI::Grok.chat(chat_opts)
|
|
747
|
-
when :ollama
|
|
748
|
-
response = PWN::AI::Ollama.chat(chat_opts)
|
|
749
|
-
when :openai
|
|
750
|
-
response = PWN::AI::OpenAI.chat(chat_opts)
|
|
751
|
-
when :openwebui
|
|
752
|
-
response = PWN::AI::OpenWebUI.chat(chat_opts)
|
|
753
|
-
else
|
|
754
|
-
raise "ERROR: Unsupported AI Engine: #{engine}"
|
|
755
|
-
end
|
|
756
|
-
|
|
757
|
-
if response.nil?
|
|
758
|
-
last_response = 'Model not currently supported with API key.'
|
|
759
|
-
else
|
|
760
|
-
if response[:choices].last.keys.include?(:text)
|
|
761
|
-
last_response = response[:choices].last[:text].to_s
|
|
762
|
-
else
|
|
763
|
-
last_response = response[:choices].last[:content].to_s
|
|
764
|
-
end
|
|
765
|
-
response_history = {
|
|
766
|
-
id: response[:id],
|
|
767
|
-
object: response[:object],
|
|
768
|
-
model: response[:model],
|
|
769
|
-
usage: response[:usage]
|
|
770
|
-
}
|
|
771
|
-
response_history[:choices] ||= response[:choices]
|
|
772
|
-
end
|
|
773
|
-
|
|
774
|
-
puts "\n\001\e[32m\002#{last_response}\001\e[0m\002\n\n"
|
|
775
|
-
if is_agent && sess_id && PWN.const_defined?(:Sessions)
|
|
776
|
-
begin
|
|
777
|
-
PWN::Sessions.append(session_id: sess_id, role: 'assistant', content: last_response)
|
|
778
|
-
rescue StandardError
|
|
779
|
-
nil
|
|
780
|
-
end
|
|
781
|
-
end
|
|
782
|
-
|
|
783
|
-
if debug
|
|
784
|
-
puts 'DEBUG: response_history => '
|
|
785
|
-
pp response_history
|
|
786
|
-
end
|
|
787
|
-
PWN::Env[:ai][engine][:response_history] = response_history
|
|
788
|
-
|
|
789
|
-
# === Agent tool execution: parse code blocks from *this* response and actually run them ===
|
|
790
|
-
tool_was_executed_this_turn = false
|
|
791
|
-
if is_agent
|
|
792
|
-
# Robust regex: tolerate language specifier, extra whitespace, and text around the block
|
|
793
|
-
last_response.scan(/```(?:\s*(ruby|bash|sh|shell|zsh))?\s*\n?(.*?)\n?```/m).each do |lang, code|
|
|
794
|
-
code = code.strip
|
|
795
|
-
next if code.empty? || tool_was_executed_this_turn
|
|
796
|
-
|
|
797
|
-
lang = (lang || 'bash').downcase
|
|
798
|
-
puts "\001\e[33m\002[ pwn-ai AGENT EXEC #{lang} ]\e[0m\002 #{code[0..90]}..."
|
|
799
|
-
|
|
800
|
-
obs = ''
|
|
801
|
-
begin
|
|
802
|
-
if lang == 'ruby'
|
|
803
|
-
require 'stringio'
|
|
804
|
-
old_stdout = $stdout
|
|
805
|
-
$stdout = StringIO.new
|
|
806
|
-
res = eval(code, TOPLEVEL_BINDING) # rubocop:disable Security/Eval -- intentional for pwn-ai agent to run PWN Ruby modules/tools in REPL context
|
|
807
|
-
captured = $stdout.string
|
|
808
|
-
$stdout = old_stdout
|
|
809
|
-
obs = (captured + "\n=> #{res.inspect}").strip
|
|
810
|
-
else
|
|
811
|
-
# CLI execution - use Open3 for cleaner capture (no extra shell if possible, but backticks are simple and work)
|
|
812
|
-
require 'open3'
|
|
813
|
-
stdout, stderr, status = Open3.capture3(code)
|
|
814
|
-
obs = stdout
|
|
815
|
-
obs += "\n[stderr]\n#{stderr}" unless stderr.to_s.strip.empty?
|
|
816
|
-
obs += "\n[exit: #{status.exitstatus}]" unless status.success?
|
|
817
|
-
obs = obs.strip
|
|
818
|
-
end
|
|
819
|
-
rescue StandardError => e
|
|
820
|
-
obs = "ERROR executing #{lang} block: #{e.class} - #{e.message}"
|
|
821
|
-
end
|
|
822
|
-
|
|
823
|
-
puts "\001\e[36m\002[OBSERVATION from #{lang}]\001\e[0m\002\n#{obs[0..700]}\n"
|
|
824
|
-
if is_agent && sess_id && PWN.const_defined?(:Sessions)
|
|
825
|
-
begin
|
|
826
|
-
PWN::Sessions.append(session_id: sess_id, role: 'observation', content: obs)
|
|
827
|
-
rescue StandardError
|
|
828
|
-
nil
|
|
829
|
-
end
|
|
830
|
-
end
|
|
831
|
-
|
|
832
|
-
# Feed real result back to the model as the next "user" message in the loop
|
|
833
|
-
curr_req = "OBSERVATION (#{lang} execution result for previous block):\n#{obs}\n\n" \
|
|
834
|
-
"Now continue fulfilling the original user request: #{orig_request}. " \
|
|
835
|
-
'If the task is complete, give the final answer (no more code blocks). Otherwise output the next needed tool block.'
|
|
836
|
-
|
|
837
|
-
tool_was_executed_this_turn = true
|
|
838
|
-
turn += 1
|
|
839
|
-
break # one execution per model turn for controlled pacing
|
|
840
|
-
end
|
|
841
|
-
end
|
|
842
|
-
|
|
843
|
-
# If we executed something, loop to let the model react to the OBS
|
|
844
|
-
next if tool_was_executed_this_turn
|
|
845
|
-
|
|
846
|
-
# No tool executed this turn -> this last_response is the final answer
|
|
847
|
-
break
|
|
848
|
-
end
|
|
849
|
-
|
|
850
|
-
# If in agent mode and the model never produced an executable block but the query clearly wanted execution,
|
|
851
|
-
# give one last chance with a strong reminder (helps weaker models like some Ollama ones)
|
|
852
|
-
if is_agent && !tool_was_executed_this_turn && orig_request =~ /`[^`]+`/ && turn < max_turns
|
|
853
|
-
reminder = 'The user explicitly asked about the output of a command in backticks. ' \
|
|
854
|
-
'Do not describe the command. Output *only* the corresponding ```bash block now so the host can run it and give you the real result.'
|
|
855
|
-
curr_req = "#{reminder}\nOriginal: #{orig_request}"
|
|
856
|
-
# One final direct call (no full re-loop to avoid complexity)
|
|
857
|
-
# (The main loop already handled most cases; this is a safety net)
|
|
858
|
-
end
|
|
859
|
-
request.replace('nil') if request.respond_to?(:replace)
|
|
860
|
-
PWN::Plugins::REPL.ready_tty!
|
|
645
|
+
PWN::Plugins::REPL.pwn_ai_run_legacy(request: request, original_request: orig_request, pry: pi)
|
|
861
646
|
end
|
|
862
647
|
end
|
|
863
648
|
|
|
@@ -944,6 +729,14 @@ module PWN
|
|
|
944
729
|
PWN.const_get(:MeshTxWin).close
|
|
945
730
|
PWN.send(:remove_const, :MeshTxWin)
|
|
946
731
|
end
|
|
732
|
+
%i[MeshSndrWin MeshRcvrWin].each do |name|
|
|
733
|
+
next unless PWN.const_defined?(name)
|
|
734
|
+
|
|
735
|
+
win = PWN.const_get(name)
|
|
736
|
+
win.close if win.respond_to?(:close)
|
|
737
|
+
PWN.send(:remove_const, name)
|
|
738
|
+
end
|
|
739
|
+
PWN.send(:remove_const, :MeshCurses) if PWN.const_defined?(:MeshCurses)
|
|
947
740
|
PWN.send(:remove_const, :MeshColors) if PWN.const_defined?(:MeshColors)
|
|
948
741
|
PWN.send(:remove_const, :MeshLastColor) if PWN.const_defined?(:MeshLastColor)
|
|
949
742
|
PWN.send(:remove_const, :MeshMutex) if PWN.const_defined?(:MeshMutex)
|