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
|
@@ -17,7 +17,7 @@ module PWN
|
|
|
17
17
|
basic_user = opts[:username]
|
|
18
18
|
basic_pass = opts[:password]
|
|
19
19
|
if (basic_user.nil? || basic_pass.nil?) && !opts[:host].to_s.empty?
|
|
20
|
-
hit = Array(PWN::Plugins::Vault.offer(host: opts[:host], service: opts[:service], engagement: opts[:engagement] || opts[:engagement_id])).first
|
|
20
|
+
hit = Array(PWN::Plugins::Vault.offer(host: opts[:host], service: opts[:service], engagement: opts[:engagement] || opts[:engagement_id], yubikey: opts[:yubikey])).first
|
|
21
21
|
if hit
|
|
22
22
|
basic_user = hit[:username] if basic_user.nil?
|
|
23
23
|
basic_pass = hit[:secret] if basic_pass.nil?
|
|
@@ -63,6 +63,7 @@ module PWN
|
|
|
63
63
|
password: 'optional - password; omitted values are filled from Vault.offer',
|
|
64
64
|
host: 'optional - hostname used to offer recon loot when username or password is omitted',
|
|
65
65
|
service: 'optional - service name such as http or ssh used to match loot',
|
|
66
|
+
yubikey: 'optional - YubiKey selector Hash forwarded to hardware-protected Vault loot',
|
|
66
67
|
engagement: 'optional - engagement id scoping the loot file (defaults to default)',
|
|
67
68
|
engagement_id: 'optional - alias for engagement'
|
|
68
69
|
)
|
|
@@ -397,25 +397,13 @@ module PWN
|
|
|
397
397
|
dec_path = dec_path.to_s.strip
|
|
398
398
|
dec_path = "#{env_path}.decryptor" if dec_path.empty?
|
|
399
399
|
|
|
400
|
-
unless File.exist?(env_path)
|
|
400
|
+
unless File.exist?(env_path)
|
|
401
401
|
puts '[*] INFO: Google Workspace OAuth tokens updated in this session only; ' \
|
|
402
|
-
"persistence to #{env_path} skipped (missing
|
|
402
|
+
"persistence to #{env_path} skipped (missing vault)."
|
|
403
403
|
return false
|
|
404
404
|
end
|
|
405
405
|
|
|
406
|
-
|
|
407
|
-
key = decryptor.is_a?(Hash) ? decryptor[:key] : nil
|
|
408
|
-
iv = decryptor.is_a?(Hash) ? decryptor[:iv] : nil
|
|
409
|
-
unless real_config_value?(value: key) && real_config_value?(value: iv)
|
|
410
|
-
puts '[*] INFO: Google Workspace OAuth tokens updated in this session only; ' \
|
|
411
|
-
'decryptor has no usable key/iv.'
|
|
412
|
-
return false
|
|
413
|
-
end
|
|
414
|
-
|
|
415
|
-
PWN::Plugins::Vault.decrypt(file: env_path, key: key, iv: iv)
|
|
416
|
-
begin
|
|
417
|
-
vault = YAML.load_file(env_path, symbolize_names: true)
|
|
418
|
-
vault = {} unless vault.is_a?(Hash)
|
|
406
|
+
PWN::Plugins::Vault.update(file: env_path, decryptor_file: dec_path) do |vault|
|
|
419
407
|
vault[:plugins] = {} unless vault[:plugins].is_a?(Hash)
|
|
420
408
|
vault[:plugins][:google_workspace] = {} unless vault[:plugins][:google_workspace].is_a?(Hash)
|
|
421
409
|
slot = vault[:plugins][:google_workspace][:oauth]
|
|
@@ -423,15 +411,10 @@ module PWN
|
|
|
423
411
|
%i[bearer_token refresh_token expires_at client_id client_secret redirect_uri scope].each do |k|
|
|
424
412
|
slot[k] = oauth[k] if oauth.key?(k) && !oauth[k].nil?
|
|
425
413
|
end
|
|
426
|
-
yaml_env = YAML.dump(vault).gsub(/^(\s*):/, '\1')
|
|
427
|
-
File.write(env_path, yaml_env)
|
|
428
|
-
File.chmod(0o600, env_path)
|
|
429
|
-
ensure
|
|
430
|
-
PWN::Plugins::Vault.encrypt(file: env_path, key: key, iv: iv)
|
|
431
414
|
end
|
|
432
415
|
true
|
|
433
416
|
rescue StandardError => e
|
|
434
|
-
warn "[!] Google Workspace OAuth vault persistence failed (session tokens still updated): #{e.class}
|
|
417
|
+
warn "[!] Google Workspace OAuth vault persistence failed (session tokens still updated): #{e.class}"
|
|
435
418
|
false
|
|
436
419
|
end
|
|
437
420
|
|
data/lib/pwn/plugins/jobs.rb
CHANGED
|
@@ -26,7 +26,7 @@ module PWN
|
|
|
26
26
|
|
|
27
27
|
root = File.expand_path(JOBS_DIR)
|
|
28
28
|
FileUtils.mkdir_p(root, mode: 0o700)
|
|
29
|
-
File.chmod(0o700, root)
|
|
29
|
+
File.chmod(0o700, root) unless (File.stat(root).mode & 0o777) == 0o700
|
|
30
30
|
runtime = Float(opts[:max_runtime] || 0)
|
|
31
31
|
raise ArgumentError, 'max_runtime must be finite and nonnegative' unless runtime.finite? && runtime >= 0
|
|
32
32
|
|
data/lib/pwn/plugins/log.rb
CHANGED
|
@@ -27,7 +27,7 @@ module PWN
|
|
|
27
27
|
PWN::AI::Agent::Loop
|
|
28
28
|
PWN::AI::Agent::Dispatch
|
|
29
29
|
].freeze
|
|
30
|
-
SECRET_KEY_RX = /password|passwd|secret|token|api[_-]?key|authorization|bearer|cookie|session[_-]?id|private[_-]?key|decryptor|credential|ssh[_-]?key|client[_-]?secret|refresh[_-]?token|access[_-]?token|id[_-]?token|vault|csrf/i
|
|
30
|
+
SECRET_KEY_RX = Regexp.union(PWN::Redaction::MASKED_FIELD, /password|passwd|secret|token|api[_-]?key|authorization|bearer|cookie|session[_-]?id|private[_-]?key|decryptor|credential|ssh[_-]?key|client[_-]?secret|refresh[_-]?token|access[_-]?token|id[_-]?token|vault|csrf/i)
|
|
31
31
|
SECRET_VALUE_RX = %r{
|
|
32
32
|
-----BEGIN\ [A-Z ]*PRIVATE\ KEY----- |
|
|
33
33
|
Bearer\s+[A-Za-z0-9\-._~+/]+=* |
|
|
@@ -644,11 +644,13 @@ module PWN
|
|
|
644
644
|
private_class_method def self.inspect_debug(opts = {})
|
|
645
645
|
val = opts[:value]
|
|
646
646
|
key = opts[:key]
|
|
647
|
+
return PWN::Redaction::CONSTANT_MASK if key.to_s.match?(PWN::Redaction::MASKED_FIELD)
|
|
647
648
|
return PWN::Redaction.token(kind: 'credential', value: val.to_s) if secret_key?(key: key)
|
|
648
649
|
return PWN::Redaction.token(kind: 'opaque', value: val) if opaque_secret?(key: key, value: val)
|
|
649
650
|
return format_debug_hash(hash: val) if val.is_a?(Hash)
|
|
650
651
|
return sanitize_debug_text(text: val) if val.is_a?(String) && val.match?(SECRET_VALUE_RX)
|
|
651
652
|
|
|
653
|
+
val = PWN::Redaction.redact(value: val) if val.is_a?(Array)
|
|
652
654
|
raw = sanitize_debug_text(text: val.inspect)
|
|
653
655
|
raw = "#{raw[0, DEBUG_VALUE_MAX]}…" if raw.length > DEBUG_VALUE_MAX
|
|
654
656
|
raw
|
|
@@ -6,9 +6,9 @@ require 'time'
|
|
|
6
6
|
|
|
7
7
|
module PWN
|
|
8
8
|
module Plugins
|
|
9
|
-
# Plugin/binary/capability preflight.
|
|
10
|
-
#
|
|
11
|
-
# the whole plugin tree.
|
|
9
|
+
# Plugin/binary/capability preflight. Native probes load host libraries
|
|
10
|
+
# only: no device enumeration, authentication or I/O. HOST summaries list
|
|
11
|
+
# degraded modules without autoloading the whole plugin tree.
|
|
12
12
|
module PreflightChecker
|
|
13
13
|
class MissingBinary < StandardError; end
|
|
14
14
|
class MissingCapability < StandardError; end
|
|
@@ -32,6 +32,13 @@ module PWN
|
|
|
32
32
|
'PWN::Plugins::Semgrep' => { bins: %w[semgrep] },
|
|
33
33
|
'PWN::Plugins::ExploitDB' => { bins: %w[searchsploit] },
|
|
34
34
|
'PWN::Plugins::Recon' => { bins: %w[subfinder httpx] },
|
|
35
|
+
'PWN::Plugins::YubiKey' => {
|
|
36
|
+
bins: [],
|
|
37
|
+
native: [
|
|
38
|
+
{ name: 'FIDO native transport', probe: 'PWN::Plugins::YubiKey', options: { application: :fido } },
|
|
39
|
+
{ name: 'PC/SC native transport', probe: 'PWN::Plugins::YubiKey', options: { application: :oath } }
|
|
40
|
+
]
|
|
41
|
+
},
|
|
35
42
|
'PWN::Plugins::CredentialAttack' => { bins: %w[hydra john hashcat] }
|
|
36
43
|
}.freeze
|
|
37
44
|
|
|
@@ -97,6 +104,11 @@ module PWN
|
|
|
97
104
|
PLUGIN_DEPS.each do |mod, dep|
|
|
98
105
|
missing = Array(dep[:bins]).reject { |b| bin?(name: b) }
|
|
99
106
|
caps[mod] = { bins: dep[:bins], missing: missing, ok: missing.empty?, caps: Array(dep[:caps]) }
|
|
107
|
+
next unless dep[:native]
|
|
108
|
+
|
|
109
|
+
missing_native = Array(dep[:native]).reject { |probe| native_available?(probe: probe) }.map { |probe| probe[:name] }
|
|
110
|
+
caps[mod][:missing_native] = missing_native
|
|
111
|
+
caps[mod][:ok] &&= missing_native.empty?
|
|
100
112
|
end
|
|
101
113
|
row = {
|
|
102
114
|
at: Time.now.utc.iso8601,
|
|
@@ -164,21 +176,25 @@ module PWN
|
|
|
164
176
|
bins = Array(dep[:bins])
|
|
165
177
|
caps = Array(dep[:caps])
|
|
166
178
|
services = Array(dep[:services])
|
|
179
|
+
native = Array(dep[:native])
|
|
167
180
|
missing_bins = bins.reject { |b| bin?(name: b) }
|
|
181
|
+
missing_native = native.reject { |probe| native_available?(probe: probe) }.map { |probe| probe[:name] }
|
|
168
182
|
missing_caps = []
|
|
169
183
|
if caps.include?('CAP_NET_RAW')
|
|
170
184
|
pkt_ok = defined?(PWN::Plugins::Packet) && PWN::Plugins::Packet.respond_to?(:health) && PWN::Plugins::Packet.health[:ok]
|
|
171
185
|
missing_caps << 'CAP_NET_RAW' unless pkt_ok || cap_net_raw?
|
|
172
186
|
end
|
|
173
187
|
missing_services = services.reject { |s| service?(name: s[:name] || s['name'], path: s[:path] || s['path']) }.map { |s| s[:name] || s['name'] }
|
|
174
|
-
status = if missing_bins.empty? && missing_caps.empty? && missing_services.empty?
|
|
188
|
+
status = if missing_bins.empty? && missing_caps.empty? && missing_services.empty? && missing_native.empty?
|
|
175
189
|
:ok
|
|
176
|
-
elsif bins.any? && missing_bins.length == bins.length
|
|
190
|
+
elsif (bins.any? && missing_bins.length == bins.length) || (native.any? && missing_native.length == native.length)
|
|
177
191
|
:dead
|
|
178
192
|
else
|
|
179
193
|
:degraded
|
|
180
194
|
end
|
|
181
|
-
{ plugin: plugin, status: status, missing_bins: missing_bins, missing_caps: missing_caps, missing_services: missing_services }
|
|
195
|
+
row = { plugin: plugin, status: status, missing_bins: missing_bins, missing_caps: missing_caps, missing_services: missing_services }
|
|
196
|
+
row[:missing_native] = missing_native if dep[:native]
|
|
197
|
+
row
|
|
182
198
|
end
|
|
183
199
|
end
|
|
184
200
|
|
|
@@ -189,7 +205,7 @@ module PWN
|
|
|
189
205
|
degraded = rows.select { |r| r[:status] == :degraded }
|
|
190
206
|
lines = ["HOST plugins ok=#{rows.count { |r| r[:status] == :ok }} degraded=#{degraded.length} dead=#{dead.length}"]
|
|
191
207
|
(degraded + dead).first(cap).each do |r|
|
|
192
|
-
miss = (Array(r[:missing_bins]) + Array(r[:missing_caps]) + Array(r[:missing_services])).join(',')
|
|
208
|
+
miss = (Array(r[:missing_bins]) + Array(r[:missing_caps]) + Array(r[:missing_services]) + Array(r[:missing_native])).join(',')
|
|
193
209
|
fb = Array(FALLBACKS[r[:plugin]]).join(',')
|
|
194
210
|
extra = fb.empty? ? '' : " fallback=#{fb}"
|
|
195
211
|
anchor = r[:plugin].to_s.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/\A-+|-+\z/, '')
|
|
@@ -198,6 +214,15 @@ module PWN
|
|
|
198
214
|
lines.join("\n")
|
|
199
215
|
end
|
|
200
216
|
|
|
217
|
+
# Only trusted declarations above may supply a host-only available? probe.
|
|
218
|
+
private_class_method def self.native_available?(opts = {})
|
|
219
|
+
probe = opts[:probe]
|
|
220
|
+
provider = probe.fetch(:probe).split('::').inject(Object) { |scope, name| scope.const_get(name, false) }
|
|
221
|
+
provider.available?(probe.fetch(:options, {})) == true
|
|
222
|
+
rescue LoadError, StandardError
|
|
223
|
+
false
|
|
224
|
+
end
|
|
225
|
+
|
|
201
226
|
public_class_method def self.authors
|
|
202
227
|
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
203
228
|
end
|
|
@@ -247,7 +272,7 @@ module PWN
|
|
|
247
272
|
path: 'required - unix socket path (e.g. /var/run/docker.sock)'
|
|
248
273
|
)
|
|
249
274
|
|
|
250
|
-
#
|
|
275
|
+
# Report dependencies; native rows add missing_native from host-only library probes, never device readiness.
|
|
251
276
|
#{self}.check(
|
|
252
277
|
names: 'optional - Array names value consumed by #check'
|
|
253
278
|
)
|
|
@@ -258,7 +283,7 @@ module PWN
|
|
|
258
283
|
limit: 'optional - limit value consumed by #host_summary'
|
|
259
284
|
)
|
|
260
285
|
|
|
261
|
-
# Refresh ~/.pwn/capabilities.json with bin/CAP_* presence.
|
|
286
|
+
# Refresh ~/.pwn/capabilities.json with bin/CAP_* and host-native-library presence; no device discovery.
|
|
262
287
|
#{self}.manifest(
|
|
263
288
|
refresh: 'optional - unused reserved refresh flag'
|
|
264
289
|
)
|
data/lib/pwn/plugins/recon.rb
CHANGED
|
@@ -138,7 +138,7 @@ module PWN
|
|
|
138
138
|
File.rename(temp, path)
|
|
139
139
|
end
|
|
140
140
|
result = JSON.parse(File.read(path), symbolize_names: true).merge(path: path)
|
|
141
|
-
result[:loot] = harvest_loot(assets: result[:assets], engagement_id: engagement)
|
|
141
|
+
result[:loot] = harvest_loot(assets: result[:assets], engagement_id: engagement, yubikey: opts[:yubikey])
|
|
142
142
|
if opts[:ingest]
|
|
143
143
|
begin
|
|
144
144
|
ingestor = opts[:ingestor] || PWN::AI::Context.method(:ingest)
|
|
@@ -285,6 +285,7 @@ module PWN
|
|
|
285
285
|
source: 'recon',
|
|
286
286
|
where: opts[:where] || "banner:#{obs[:port] || asset[:port]}",
|
|
287
287
|
engagement: eng,
|
|
288
|
+
yubikey: opts[:yubikey],
|
|
288
289
|
finding_id: opts[:finding_id]
|
|
289
290
|
)
|
|
290
291
|
end
|
|
@@ -354,6 +355,7 @@ module PWN
|
|
|
354
355
|
modules: 'optional - Array nmap/banner/tls/subfinder/nuclei; defaults nmap/banner',
|
|
355
356
|
ports: 'optional - Array of integer ports; defaults 80 and 443',
|
|
356
357
|
engagement_id: 'optional - simple identifier; defaults default',
|
|
358
|
+
yubikey: 'optional - hardware selector Hash for harvested loot; device failure raises without software fallback',
|
|
357
359
|
root: 'optional - engagements directory; defaults ~/.pwn/engagements',
|
|
358
360
|
timeout: 'optional - per-operation timeout seconds; defaults 15, maximum 300',
|
|
359
361
|
ingest: 'optional - invoke Context.ingest after persistence; defaults false',
|
|
@@ -366,6 +368,7 @@ module PWN
|
|
|
366
368
|
assets: 'required - Array of recon asset hashes with address and observations',
|
|
367
369
|
engagement_id: 'optional - engagement id scoping the loot file (defaults to default)',
|
|
368
370
|
engagement: 'optional - alias for engagement_id',
|
|
371
|
+
yubikey: 'optional - hardware selector Hash forwarded to Vault.ingest for captured secrets',
|
|
369
372
|
where: 'optional - provenance path overlay when observation port is missing',
|
|
370
373
|
finding_id: 'optional - finding id linked onto harvested secrets'
|
|
371
374
|
)
|
data/lib/pwn/plugins/repl/ai.rb
CHANGED
|
@@ -278,9 +278,294 @@ module PWN
|
|
|
278
278
|
# 2. '/' anywhere else → :path (host-native file nav)
|
|
279
279
|
# 3. else → :ruby (same Pry::InputCompleter menu as the pwn REPL)
|
|
280
280
|
class << PWN::Plugins::REPL # rubocop:disable Metrics/ClassLength
|
|
281
|
-
#
|
|
282
|
-
# readline and not a worker thread running tools without their locals.
|
|
281
|
+
# The terminal owner survives CTRL+C; only its mission tree is killed.
|
|
283
282
|
def pwn_ai_run_steerable(opts = {})
|
|
283
|
+
pwn_ai_run_mission(opts.merge(operation: :steerable))
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Both native tools and regex-ReAct must execute below the same owner.
|
|
287
|
+
# Never retry a failed mission in the terminal/controller process.
|
|
288
|
+
def pwn_ai_run_legacy(opts = {})
|
|
289
|
+
result = pwn_ai_run_mission(opts.merge(operation: :legacy))
|
|
290
|
+
PWN::Env[:ai][result.fetch(:engine).to_sym][:response_history] = result[:response_history]
|
|
291
|
+
rescue StandardError => e
|
|
292
|
+
warn "[pwn-ai] legacy agent mission failed (#{e.class}: #{e.message.to_s.split("\n").first}); no uncontained retry was attempted"
|
|
293
|
+
ensure
|
|
294
|
+
opts[:request]&.replace('nil')
|
|
295
|
+
PWN::Plugins::REPL.ready_tty!
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def pwn_ai_run_mission(opts = {})
|
|
299
|
+
require 'io/console'
|
|
300
|
+
input = opts[:input] || $stdin
|
|
301
|
+
reader, writer = IO.pipe
|
|
302
|
+
run = proc do
|
|
303
|
+
config = %i[pwn_ai_session_id pwn_ai_debug pwn_ai_trace pwn_ai_speak pwn_ai_agent].to_h { |key| [key, opts[:pry]&.config&.public_send(key)] }
|
|
304
|
+
descriptor = { operation: opts.fetch(:operation), config: config, options: opts.except(:pry, :input, :on_tool, :steering, :debug_tee) }
|
|
305
|
+
mission = PWN::Plugins::AISandbox::MissionProcess.new(descriptor: descriptor, fds: { 4 => writer, 0 => input })
|
|
306
|
+
writer.close
|
|
307
|
+
envelope = {}
|
|
308
|
+
reader.each_line do |line|
|
|
309
|
+
frame = JSON.parse(line, symbolize_names: true)
|
|
310
|
+
if frame[:event] == 'tool'
|
|
311
|
+
opts[:on_tool]&.call(*frame[:args])
|
|
312
|
+
else
|
|
313
|
+
envelope = frame
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
raise IOError, envelope[:error] if envelope[:error]
|
|
317
|
+
|
|
318
|
+
envelope[:result]
|
|
319
|
+
ensure
|
|
320
|
+
mission&.close
|
|
321
|
+
end
|
|
322
|
+
input.tty? ? input.cooked(&run) : run.call
|
|
323
|
+
ensure
|
|
324
|
+
[reader, writer].compact.each { |io| io.close unless io.closed? }
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def pwn_ai_mission_entry(opts = {})
|
|
328
|
+
pry = Pry.new
|
|
329
|
+
opts.fetch(:config).each { |key, value| pry.config.public_send("#{key}=", value) }
|
|
330
|
+
writer = PWN::AI::Agent::MissionNamespace.io(4)
|
|
331
|
+
options = { trace: pry.config.pwn_ai_trace }.merge(opts.fetch(:options)).merge(
|
|
332
|
+
pry: pry, input: PWN::AI::Agent::MissionNamespace.io(0),
|
|
333
|
+
on_tool: ->(*args) { writer.puts(JSON.generate(event: 'tool', args: args)) }
|
|
334
|
+
)
|
|
335
|
+
begin
|
|
336
|
+
result = opts[:operation] == :legacy ? pwn_ai_run_legacy_child(options) : pwn_ai_run_steerable_child(options)
|
|
337
|
+
writer.puts(JSON.generate(result: result))
|
|
338
|
+
rescue StandardError => e
|
|
339
|
+
writer.puts(JSON.generate(error: "#{e.class}: #{e.message}"))
|
|
340
|
+
ensure
|
|
341
|
+
writer.close
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def pwn_ai_run_legacy_child(opts = {})
|
|
346
|
+
request = opts[:request]
|
|
347
|
+
orig_request = opts[:original_request]
|
|
348
|
+
pi = opts[:pry]
|
|
349
|
+
# Do NOT rebind the 'request' parameter (the string object passed by Pry's after_read hook).
|
|
350
|
+
# We will mutate it to 'nil' at the end of handling so Pry does not eval the natural-language
|
|
351
|
+
# prompt text as Ruby (which was causing noisy exceptions *after* the green AI response print).
|
|
352
|
+
debug = pi.config.pwn_ai_debug
|
|
353
|
+
engine = PWN::Env[:ai][:active].to_s.downcase.to_sym
|
|
354
|
+
response_history = PWN::Env[:ai][engine][:response_history]
|
|
355
|
+
speak_answer = pi.config.pwn_ai_speak
|
|
356
|
+
is_agent = (pi.config.pwn_ai_agent == true)
|
|
357
|
+
|
|
358
|
+
# pwn-ai agent mode: load skills context for autonomous task carrying
|
|
359
|
+
skills_context = ''
|
|
360
|
+
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)
|
|
361
|
+
|
|
362
|
+
memory_context = ''
|
|
363
|
+
memory_context = PWN::Memory.to_context(limit: 25) if is_agent && PWN.const_defined?(:Memory)
|
|
364
|
+
|
|
365
|
+
sess_id = begin
|
|
366
|
+
pi.config.pwn_ai_session_id
|
|
367
|
+
rescue StandardError
|
|
368
|
+
nil
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Pre-process for clear CLI execution intent (e.g. "what does `id` return?")
|
|
372
|
+
# This makes the agent actually *run* commands instead of just explaining them.
|
|
373
|
+
curr_req = orig_request.chomp
|
|
374
|
+
if is_agent && sess_id && PWN.const_defined?(:Sessions)
|
|
375
|
+
begin
|
|
376
|
+
PWN::Sessions.append(session_id: sess_id, role: 'user', content: orig_request)
|
|
377
|
+
rescue StandardError
|
|
378
|
+
nil
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
if is_agent && request =~ /`([^`]+)`/
|
|
382
|
+
potential = ::Regexp.last_match(1).strip
|
|
383
|
+
# Looks like a shell command (not PWN ruby)
|
|
384
|
+
unless potential =~ /^(PWN::|def |class |require |puts |pp )/
|
|
385
|
+
curr_req = "The user wants the *actual raw output* of this command (do not just describe it): `#{potential}`. " \
|
|
386
|
+
'To fulfill the request accurately, you MUST immediately output ONLY a bash code block with the exact command. ' \
|
|
387
|
+
"Example format: ```bash\n#{potential}\n``` . After the host executes it, you will receive the OBSERVATION with the real output."
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Strict system prompt for agent mode (forces tool use over explanation)
|
|
392
|
+
system_role = nil
|
|
393
|
+
if is_agent
|
|
394
|
+
base = PWN::Env[:ai][engine][:system_role_content] || 'You are an ethical hacker.'
|
|
395
|
+
system_role = base + <<~PROMPT
|
|
396
|
+
|
|
397
|
+
You are operating as an autonomous agent inside the PWN REPL driver.
|
|
398
|
+
|
|
399
|
+
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.#{' '}
|
|
400
|
+
NEVER just explain what a command does or what its output "would be".#{' '}
|
|
401
|
+
To execute anything:
|
|
402
|
+
- Output *exactly and only* a fenced code block.
|
|
403
|
+
- For shell/CLI: ```bash
|
|
404
|
+
<exact command here>
|
|
405
|
+
```
|
|
406
|
+
- For PWN Ruby modules: ```ruby
|
|
407
|
+
PWN::Plugins::NmapIt.port_scan(...)
|
|
408
|
+
```
|
|
409
|
+
The host will execute it (Ruby in full PWN context, bash via shell) and reply with an OBSERVATION containing the real result.#{' '}
|
|
410
|
+
Then continue or give the final answer.
|
|
411
|
+
|
|
412
|
+
Available tools include all PWN::Plugins (NmapIt, TransparentBrowser, etc.), SAST, Reports, and any CLI via bash blocks.
|
|
413
|
+
Skills available this session:#{skills_context}
|
|
414
|
+
#{memory_context}
|
|
415
|
+
|
|
416
|
+
PERSISTENT CAPABILITIES (use via ruby code blocks or direct calls):
|
|
417
|
+
- Memory (cross-session): PWN::Memory.remember(key: :key, value: val, category: :fact|:preference|:lesson)
|
|
418
|
+
PWN::Memory.recall(query: 'foo'), PWN::Memory.forget(key: key)
|
|
419
|
+
- Sessions: current session id = #{sess_id}; PWN::Sessions.append(session_id: '#{sess_id}', role: 'observation', content: obs)
|
|
420
|
+
- Cron: PWN::Cron.create(schedule: '0 * * * *', prompt: 'task here', name: 'foo')
|
|
421
|
+
PWN::Cron.run(id: 'id'); list with PWN::Cron.list
|
|
422
|
+
- Agents/Delegation: PWN::AI::Agent::SAST.analyze(request: ...); PWN::AI::Agent::VulnGen etc.
|
|
423
|
+
For sub-agents use threads or separate eval calls and feed results back as OBS.
|
|
424
|
+
|
|
425
|
+
After receiving an observation, decide the next step or conclude.
|
|
426
|
+
If you output text without a code block, it will be treated as your final answer to the user.
|
|
427
|
+
PROMPT
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
max_turns = is_agent ? 7 : 1
|
|
431
|
+
turn = 0
|
|
432
|
+
last_response = ''
|
|
433
|
+
tool_was_executed_this_turn = false
|
|
434
|
+
|
|
435
|
+
while turn < max_turns
|
|
436
|
+
chat_opts = {
|
|
437
|
+
request: curr_req,
|
|
438
|
+
response_history: response_history,
|
|
439
|
+
speak_answer: speak_answer,
|
|
440
|
+
spinner: false
|
|
441
|
+
}
|
|
442
|
+
chat_opts[:system_role_content] = system_role if system_role
|
|
443
|
+
|
|
444
|
+
case engine
|
|
445
|
+
when :anthropic
|
|
446
|
+
response = PWN::AI::Anthropic.chat(chat_opts)
|
|
447
|
+
when :gemini
|
|
448
|
+
response = PWN::AI::Gemini.chat(chat_opts)
|
|
449
|
+
when :grok
|
|
450
|
+
response = PWN::AI::Grok.chat(chat_opts)
|
|
451
|
+
when :ollama
|
|
452
|
+
response = PWN::AI::Ollama.chat(chat_opts)
|
|
453
|
+
when :openai
|
|
454
|
+
response = PWN::AI::OpenAI.chat(chat_opts)
|
|
455
|
+
when :openwebui
|
|
456
|
+
response = PWN::AI::OpenWebUI.chat(chat_opts)
|
|
457
|
+
else
|
|
458
|
+
raise "ERROR: Unsupported AI Engine: #{engine}"
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
if response.nil?
|
|
462
|
+
last_response = 'Model not currently supported with API key.'
|
|
463
|
+
else
|
|
464
|
+
if response[:choices].last.keys.include?(:text)
|
|
465
|
+
last_response = response[:choices].last[:text].to_s
|
|
466
|
+
else
|
|
467
|
+
last_response = response[:choices].last[:content].to_s
|
|
468
|
+
end
|
|
469
|
+
response_history = {
|
|
470
|
+
id: response[:id],
|
|
471
|
+
object: response[:object],
|
|
472
|
+
model: response[:model],
|
|
473
|
+
usage: response[:usage]
|
|
474
|
+
}
|
|
475
|
+
response_history[:choices] ||= response[:choices]
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
puts "\n\001\e[32m\002#{last_response}\001\e[0m\002\n\n"
|
|
479
|
+
if is_agent && sess_id && PWN.const_defined?(:Sessions)
|
|
480
|
+
begin
|
|
481
|
+
PWN::Sessions.append(session_id: sess_id, role: 'assistant', content: last_response)
|
|
482
|
+
rescue StandardError
|
|
483
|
+
nil
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
if debug
|
|
488
|
+
puts 'DEBUG: response_history => '
|
|
489
|
+
pp response_history
|
|
490
|
+
end
|
|
491
|
+
PWN::Env[:ai][engine][:response_history] = response_history
|
|
492
|
+
|
|
493
|
+
# === Agent tool execution: parse code blocks from *this* response and actually run them ===
|
|
494
|
+
tool_was_executed_this_turn = false
|
|
495
|
+
if is_agent
|
|
496
|
+
# Robust regex: tolerate language specifier, extra whitespace, and text around the block
|
|
497
|
+
last_response.scan(/```(?:\s*(ruby|bash|sh|shell|zsh))?\s*\n?(.*?)\n?```/m).each do |lang, code|
|
|
498
|
+
code = code.strip
|
|
499
|
+
next if code.empty? || tool_was_executed_this_turn
|
|
500
|
+
|
|
501
|
+
lang = (lang || 'bash').downcase
|
|
502
|
+
puts "\001\e[33m\002[ pwn-ai AGENT EXEC #{lang} ]\e[0m\002 #{code[0..90]}..."
|
|
503
|
+
|
|
504
|
+
obs = ''
|
|
505
|
+
begin
|
|
506
|
+
obs = PWN::Plugins::AISandbox.protect_call do
|
|
507
|
+
if lang == 'ruby'
|
|
508
|
+
require 'stringio'
|
|
509
|
+
old_stdout = $stdout
|
|
510
|
+
$stdout = StringIO.new
|
|
511
|
+
res = eval(code, TOPLEVEL_BINDING) # rubocop:disable Security/Eval -- intentional sandboxed legacy tool evaluation
|
|
512
|
+
captured = $stdout.string
|
|
513
|
+
$stdout = old_stdout
|
|
514
|
+
(captured + "\n=> #{res.inspect}").strip
|
|
515
|
+
else
|
|
516
|
+
require 'open3'
|
|
517
|
+
stdout, stderr, status = Open3.capture3(code)
|
|
518
|
+
output = stdout
|
|
519
|
+
output += "\n[stderr]\n#{stderr}" unless stderr.to_s.strip.empty?
|
|
520
|
+
output += "\n[exit: #{status.exitstatus}]" unless status.success?
|
|
521
|
+
output.strip
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
obs = JSON.generate(obs) unless obs.is_a?(String)
|
|
525
|
+
rescue StandardError => e
|
|
526
|
+
obs = "ERROR executing #{lang} block: #{e.class} - #{e.message}"
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
puts "\001\e[36m\002[OBSERVATION from #{lang}]\001\e[0m\002\n#{obs[0..700]}\n"
|
|
530
|
+
if is_agent && sess_id && PWN.const_defined?(:Sessions)
|
|
531
|
+
begin
|
|
532
|
+
PWN::Sessions.append(session_id: sess_id, role: 'observation', content: obs)
|
|
533
|
+
rescue StandardError
|
|
534
|
+
nil
|
|
535
|
+
end
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# Feed real result back to the model as the next "user" message in the loop
|
|
539
|
+
curr_req = "OBSERVATION (#{lang} execution result for previous block):\n#{obs}\n\n" \
|
|
540
|
+
"Now continue fulfilling the original user request: #{orig_request}. " \
|
|
541
|
+
'If the task is complete, give the final answer (no more code blocks). Otherwise output the next needed tool block.'
|
|
542
|
+
|
|
543
|
+
tool_was_executed_this_turn = true
|
|
544
|
+
turn += 1
|
|
545
|
+
break # one execution per model turn for controlled pacing
|
|
546
|
+
end
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# If we executed something, loop to let the model react to the OBS
|
|
550
|
+
next if tool_was_executed_this_turn
|
|
551
|
+
|
|
552
|
+
# No tool executed this turn -> this last_response is the final answer
|
|
553
|
+
break
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
# If in agent mode and the model never produced an executable block but the query clearly wanted execution,
|
|
557
|
+
# give one last chance with a strong reminder (helps weaker models like some Ollama ones)
|
|
558
|
+
if is_agent && !tool_was_executed_this_turn && orig_request =~ /`[^`]+`/ && turn < max_turns
|
|
559
|
+
reminder = 'The user explicitly asked about the output of a command in backticks. ' \
|
|
560
|
+
'Do not describe the command. Output *only* the corresponding ```bash block now so the host can run it and give you the real result.'
|
|
561
|
+
curr_req = "#{reminder}\nOriginal: #{orig_request}"
|
|
562
|
+
# One final direct call (no full re-loop to avoid complexity)
|
|
563
|
+
# (The main loop already handled most cases; this is a safety net)
|
|
564
|
+
end
|
|
565
|
+
{ engine: engine, response_history: response_history }
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
def pwn_ai_run_steerable_child(opts = {})
|
|
284
569
|
previous = Thread.current[:pwn_steering_input]
|
|
285
570
|
workspace = File.realpath(Dir.pwd)
|
|
286
571
|
lock_path = File.join(Dir.tmpdir, "pwn-solve-#{Digest::SHA256.hexdigest(workspace)}.lock")
|
|
@@ -862,24 +1147,11 @@ module PWN
|
|
|
862
1147
|
env_path = File.join(Dir.home, '.pwn', 'pwn.yaml') if env_path.empty?
|
|
863
1148
|
dec_path = dec_path.to_s.strip
|
|
864
1149
|
dec_path = "#{env_path}.decryptor" if dec_path.empty?
|
|
865
|
-
return false unless File.exist?(env_path)
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
return false if key.to_s.strip.empty? || iv.to_s.strip.empty?
|
|
871
|
-
|
|
872
|
-
# Never decrypt the live vault in place: failed encryption must leave
|
|
873
|
-
# the previous ciphertext intact. Rename only the encrypted merge.
|
|
874
|
-
require 'tempfile'
|
|
875
|
-
Tempfile.create(['.pwn-ai-selection', '.yaml'], File.dirname(env_path)) do |file|
|
|
876
|
-
file.binmode
|
|
877
|
-
file.write(File.binread(env_path))
|
|
878
|
-
file.flush
|
|
879
|
-
path = file.path
|
|
880
|
-
PWN::Plugins::Vault.decrypt(file: path, key: key, iv: iv)
|
|
881
|
-
cfg = YAML.load_file(path, symbolize_names: true)
|
|
882
|
-
cfg = {} unless cfg.is_a?(Hash)
|
|
1150
|
+
return false unless File.exist?(env_path)
|
|
1151
|
+
|
|
1152
|
+
# Shared update preserves hardware or legacy format and publishes only
|
|
1153
|
+
# ciphertext after a locked re-read and final conflict comparison.
|
|
1154
|
+
PWN::Plugins::Vault.update(file: env_path, decryptor_file: dec_path) do |cfg|
|
|
883
1155
|
cfg[:ai] = {} unless cfg[:ai].is_a?(Hash)
|
|
884
1156
|
cfg[:ai][:active] = engine unless opts.key?(:system_role_content)
|
|
885
1157
|
unless model.to_s.strip.empty?
|
|
@@ -895,15 +1167,10 @@ module PWN
|
|
|
895
1167
|
cfg[:ai][engine.to_sym] ||= {}
|
|
896
1168
|
cfg[:ai][engine.to_sym][:system_role_content] = opts[:system_role_content]
|
|
897
1169
|
end
|
|
898
|
-
yaml_env = YAML.dump(cfg).gsub(/^(\s*):/, '\1')
|
|
899
|
-
File.write(path, yaml_env)
|
|
900
|
-
File.chmod(0o600, path)
|
|
901
|
-
PWN::Plugins::Vault.encrypt(file: path, key: key, iv: iv)
|
|
902
|
-
File.rename(path, env_path)
|
|
903
1170
|
end
|
|
904
1171
|
true
|
|
905
1172
|
rescue StandardError => e
|
|
906
|
-
warn "[pwn-ai] settings persist failed: #{e.class}
|
|
1173
|
+
warn "[pwn-ai] settings persist failed: #{e.class}; check vault access and retry."
|
|
907
1174
|
false
|
|
908
1175
|
end
|
|
909
1176
|
|