pwn 0.5.783 → 0.5.785
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 +8 -8
- data/README.md +37 -0
- data/Rakefile +19 -1
- data/bin/pwn +2 -1
- data/bin/pwn_setup +1 -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 +82 -0
- data/documentation/Configuration.md +10 -6
- data/documentation/Contributing.md +1 -1
- data/documentation/Cron.md +64 -35
- data/documentation/Diagrams.md +2 -2
- data/documentation/General-PWN-Usage.md +4 -3
- data/documentation/Home.md +6 -5
- data/documentation/How-PWN-Works.md +7 -6
- data/documentation/Installation.md +254 -10
- data/documentation/Mission-Cancellation.md +134 -0
- data/documentation/Operability.md +17 -0
- data/documentation/Persistence.md +8 -6
- data/documentation/Plugin-Degradation.md +12 -0
- data/documentation/Plugins.md +3 -2
- data/documentation/Reinforcement-Learning.md +5 -6
- 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 +183 -0
- data/documentation/YubiKey.md +339 -0
- data/documentation/diagrams/cron-scheduling.svg +130 -128
- data/documentation/diagrams/dot/cron-scheduling.dot +5 -5
- data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
- data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
- data/documentation/diagrams/persistence-filesystem.svg +172 -142
- data/documentation/diagrams/plugin-ecosystem.svg +64 -64
- data/documentation/pwn-ai-Agent.md +10 -4
- data/etc/default_cron/session_cleanup.rb +1245 -22
- 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/extrospection.rb +6 -7
- data/lib/pwn/ai/agent/learning.rb +62 -25
- 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.rb +5 -0
- data/lib/pwn/ai/agent/policy_evaluation.rb +8 -8
- 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 +35 -16
- 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 +53 -3
- data/lib/pwn/ai/server_tools.rb +231 -0
- data/lib/pwn/ai.rb +1 -0
- data/lib/pwn/backups.rb +347 -0
- data/lib/pwn/config.rb +70 -39
- data/lib/pwn/cron.rb +120 -49
- data/lib/pwn/migrate.rb +150 -68
- 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/binary_parser.rb +5 -4
- data/lib/pwn/plugins/ghidra.rb +4 -3
- data/lib/pwn/plugins/google_workspace.rb +4 -21
- data/lib/pwn/plugins/jobs.rb +1 -1
- data/lib/pwn/plugins/log.rb +21 -3
- data/lib/pwn/plugins/preflight_checker.rb +34 -9
- data/lib/pwn/plugins/recon.rb +4 -1
- data/lib/pwn/plugins/repl/ai.rb +305 -27
- data/lib/pwn/plugins/repl/ai_console.rb +273 -89
- data/lib/pwn/plugins/repl/ai_swarm.rb +181 -62
- data/lib/pwn/plugins/repl/mesh.rb +3 -16
- data/lib/pwn/plugins/repl/vault.rb +4 -15
- data/lib/pwn/plugins/repl.rb +1 -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/sessions.rb +58 -16
- data/lib/pwn/setup.rb +123 -41
- 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/scripts/build_gate.rb +58 -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/build_gate_spec.rb +73 -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/persistence_roundtrip_spec.rb +1 -7
- 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_spec.rb +27 -0
- 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 +6 -9
- 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 +31 -3
- data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
- data/spec/lib/pwn/backups_spec.rb +114 -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/cron_spec.rb +113 -6
- data/spec/lib/pwn/migrate_spec.rb +108 -5
- 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_spec.rb +7 -0
- 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 +125 -78
- data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
- data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +30 -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_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_spec.rb +13 -10
- data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
- data/spec/lib/pwn/session_cleanup_concurrency_spec.rb +201 -0
- data/spec/lib/pwn/session_cleanup_default_spec.rb +167 -2
- data/spec/lib/pwn/session_cleanup_legacy_spec.rb +101 -0
- data/spec/lib/pwn/session_cleanup_payload_spec.rb +335 -2
- data/spec/lib/pwn/session_cleanup_recovery_spec.rb +403 -0
- data/spec/lib/pwn/sessions_spec.rb +112 -0
- data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
- data/spec/lib/pwn/setup_spec.rb +3 -1
- 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/gate_failure_formatter.rb +14 -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 +599 -15
- metadata +140 -17
|
@@ -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
|
|
@@ -164,7 +187,7 @@ module PWN
|
|
|
164
187
|
when 'create' then create(tokens.join(' '))
|
|
165
188
|
when 'use' then use(tokens.first)
|
|
166
189
|
when 'spawn' then spawn(tokens)
|
|
167
|
-
when 'retire' then retire(tokens.first)
|
|
190
|
+
when 'retire' then retire(tokens.first, continuation: tokens.include?('--continuation'))
|
|
168
191
|
when 'dm' then ask(tokens, opts)
|
|
169
192
|
when 'mission' then solve(tokens, opts)
|
|
170
193
|
when 'debate' then debate(tokens, opts)
|
|
@@ -180,7 +203,7 @@ module PWN
|
|
|
180
203
|
def help_text
|
|
181
204
|
{
|
|
182
205
|
ok: true,
|
|
183
|
-
usage: '/swarm mission REQUEST|agents|status [JOB]|create [topic]|use ID|spawn NAME ROLE [--engine E --model M --toolsets a,b]|retire NAME|dm NAME REQUEST|debate NAMES TOPIC [--rounds N]|broadcast REQUEST [--names a,b]|tail [N]|steer JOB INSTRUCTION|pause JOB|resume JOB|cancel JOB|all'
|
|
206
|
+
usage: '/swarm mission REQUEST|agents|status [JOB]|create [topic]|use ID|spawn NAME ROLE [--engine E --model M --toolsets a,b]|retire NAME [--continuation]|dm NAME REQUEST|debate NAMES TOPIC [--rounds N]|broadcast REQUEST [--names a,b]|tail [N]|steer JOB INSTRUCTION|pause JOB|resume JOB|cancel JOB|all'
|
|
184
207
|
}
|
|
185
208
|
end
|
|
186
209
|
|
|
@@ -215,10 +238,10 @@ module PWN
|
|
|
215
238
|
{ ok: true, persona: result[:name], swarm_id: @swarm_id, scope: :swarm }
|
|
216
239
|
end
|
|
217
240
|
|
|
218
|
-
def retire(name)
|
|
241
|
+
def retire(name, continuation: false)
|
|
219
242
|
return { ok: false, error: 'invalid persona name' } unless identifier?(name)
|
|
220
243
|
|
|
221
|
-
result = PWN::AI::Agent::Swarm.retire(name: name, swarm_id: @swarm_id, local: true)
|
|
244
|
+
result = PWN::AI::Agent::Swarm.retire(name: name, swarm_id: @swarm_id, local: true, continuation: continuation)
|
|
222
245
|
{ ok: result[:removed], scope: result[:scope], name: name }
|
|
223
246
|
end
|
|
224
247
|
|
|
@@ -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
|
|
@@ -3906,17 +3906,9 @@ class << PWN::Plugins::REPL # rubocop:disable Metrics/ClassLength
|
|
|
3906
3906
|
env_path = File.join(Dir.home, '.pwn', 'pwn.yaml') if env_path.empty?
|
|
3907
3907
|
dec_path = dec_path.to_s.strip
|
|
3908
3908
|
dec_path = "#{env_path}.decryptor" if dec_path.empty?
|
|
3909
|
-
return false unless File.exist?(env_path)
|
|
3909
|
+
return false unless File.exist?(env_path)
|
|
3910
3910
|
|
|
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)
|
|
3911
|
+
PWN::Plugins::Vault.update(file: env_path, decryptor_file: dec_path) do |cfg|
|
|
3920
3912
|
cfg[:plugins] = {} unless cfg[:plugins].is_a?(Hash)
|
|
3921
3913
|
dst = cfg[:plugins][:meshtastic]
|
|
3922
3914
|
dst = {} unless dst.is_a?(Hash)
|
|
@@ -3933,15 +3925,10 @@ class << PWN::Plugins::REPL # rubocop:disable Metrics/ClassLength
|
|
|
3933
3925
|
end
|
|
3934
3926
|
end
|
|
3935
3927
|
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
3928
|
end
|
|
3942
3929
|
true
|
|
3943
3930
|
rescue StandardError => e
|
|
3944
|
-
mesh_ui_puts(text: "[pwn-mesh] persist skipped: #{e.class}
|
|
3931
|
+
mesh_ui_puts(text: "[pwn-mesh] persist skipped: #{e.class}")
|
|
3945
3932
|
false
|
|
3946
3933
|
end
|
|
3947
3934
|
|
|
@@ -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
|
|
|
@@ -155,6 +155,10 @@ module PWNSandboxDriver
|
|
|
155
155
|
command = ['bwrap', '--unshare-all', '--die-with-parent', '--new-session', '--cap-drop', 'ALL', '--clearenv', '--setenv', 'PATH', '/usr/bin:/bin', '--ro-bind', '/usr', '/usr']
|
|
156
156
|
%w[/lib /lib64 /bin /sbin].each { |name| command.push('--ro-bind', name, name) if File.exist?(name) }
|
|
157
157
|
command.push('--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp', '--ro-bind', artifacts, '/artifacts', '--chdir', '/tmp', File.realpath('/usr/bin/ruby'), '/artifacts/worker.rb', '--inside')
|
|
158
|
+
# A nonroot caller inside a user namespace may retain capabilities;
|
|
159
|
+
# bubblewrap rejects those before creating its own namespace.
|
|
160
|
+
effective = File.read('/proc/self/status')[/^CapEff:\s+(\h+)/, 1].to_i(16)
|
|
161
|
+
command.unshift('setpriv', '--ambient-caps=-all', '--inh-caps=-all', '--bounding-set=-all', '--') if !Process.uid.zero? && effective.positive?
|
|
158
162
|
end
|
|
159
163
|
begin
|
|
160
164
|
result = bounded(command, (seconds * 3) + 10, JSON.generate(payload))
|