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,7 +49,8 @@ module PWN
|
|
|
49
49
|
|
|
50
50
|
ENVIRONMENT
|
|
51
51
|
host : #{host_line}
|
|
52
|
-
cwd : #{
|
|
52
|
+
cwd : #{Thread.current[:pwn_request_runtime]&.contract&.dig(:cwd) || PWN::Plugins::AISandbox.task_directory}
|
|
53
|
+
writes : new files go to #{PWN::Plugins::AISandbox.workspace} unless the original operator request names another path
|
|
53
54
|
ruby : #{RUBY_VERSION}
|
|
54
55
|
pwn : #{pwn_version}
|
|
55
56
|
session_id : #{session_id || '(none)'}
|
|
@@ -77,7 +78,8 @@ module PWN
|
|
|
77
78
|
|
|
78
79
|
ENVIRONMENT
|
|
79
80
|
host : #{host_line}
|
|
80
|
-
cwd : #{
|
|
81
|
+
cwd : #{Thread.current[:pwn_request_runtime]&.contract&.dig(:cwd) || PWN::Plugins::AISandbox.task_directory}
|
|
82
|
+
writes : new files go to #{PWN::Plugins::AISandbox.workspace} unless the original operator request names another path
|
|
81
83
|
ruby : #{RUBY_VERSION}
|
|
82
84
|
pwn : #{pwn_version}
|
|
83
85
|
session_id : #{session_id || '(none)'}
|
|
@@ -100,6 +102,9 @@ module PWN
|
|
|
100
102
|
resolved mistake or a durable procedure change, `skills_update`
|
|
101
103
|
folds that RL note into the matching skill.
|
|
102
104
|
TransparentBrowser: open once, reuse browser_obj, close once.
|
|
105
|
+
If Open WebUI lists a Jev model, browser_act uses Jev for the
|
|
106
|
+
click/type/select decision and the active model only for field text.
|
|
107
|
+
#{workspace_prompt(engine: engine)}
|
|
103
108
|
|
|
104
109
|
HOST LOAD
|
|
105
110
|
#{host_load_block}
|
|
@@ -195,6 +200,12 @@ module PWN
|
|
|
195
200
|
''
|
|
196
201
|
end
|
|
197
202
|
|
|
203
|
+
private_class_method def self.workspace_prompt(opts = {})
|
|
204
|
+
return '' unless opts[:engine].to_s == 'openwebui'
|
|
205
|
+
|
|
206
|
+
"Open WebUI workspace tools, including Ask Jev when it is installed, run on the server through workspace_tool. Do not put them in the client tools array.\n"
|
|
207
|
+
end
|
|
208
|
+
|
|
198
209
|
private_class_method def self.host_line
|
|
199
210
|
`uname -srm 2>/dev/null`.strip
|
|
200
211
|
rescue StandardError
|
|
@@ -24,7 +24,7 @@ module PWN
|
|
|
24
24
|
@request_id = (opts[:request_id] || SecureRandom.uuid).to_s.freeze
|
|
25
25
|
@original_request = PWN::AI::Agent::Result.utf8_text(text: opts[:request]).freeze
|
|
26
26
|
@request = @original_request
|
|
27
|
-
@cwd =
|
|
27
|
+
@cwd = Thread.current[:pwn_solve_workspace] || PWN::Plugins::AISandbox.task_directory
|
|
28
28
|
@amendments = [].freeze
|
|
29
29
|
@observations = {}
|
|
30
30
|
@events = []
|
|
@@ -87,10 +87,50 @@ module PWN
|
|
|
87
87
|
mode == 'cancel' ? finish!(status: 'cancelled') : checkpoint!
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
+
# Private in-memory exec handoff. Locks and cancellation callbacks belong
|
|
91
|
+
# to the caller; the enclosing namespace is the child's cancellation owner.
|
|
92
|
+
def mission_snapshot
|
|
93
|
+
@mutex.synchronize do
|
|
94
|
+
state = instance_variables.reject { |key| %i[@mutex @cancel @memory @router].include?(key) }.to_h { |key| [key, instance_variable_get(key)] }
|
|
95
|
+
state[:memory] = @memory.instance_variables.to_h { |key| [key, @memory.instance_variable_get(key)] }
|
|
96
|
+
state[:router] = @router.instance_variables.to_h { |key| [key, @router.instance_variable_get(key)] }
|
|
97
|
+
state
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private_class_method def self.from_mission_snapshot(opts = {})
|
|
102
|
+
state = opts.fetch(:state).dup
|
|
103
|
+
runtime = allocate
|
|
104
|
+
{ memory: EngagementMemory, router: Profiles }.each do |key, klass|
|
|
105
|
+
object = klass.allocate
|
|
106
|
+
state.delete(key).each { |name, value| object.instance_variable_set(name, value) }
|
|
107
|
+
runtime.instance_variable_set(:"@#{key}", object)
|
|
108
|
+
end
|
|
109
|
+
state.each { |name, value| runtime.instance_variable_set(name, value) }
|
|
110
|
+
runtime.instance_variable_set(:@mutex, Mutex.new)
|
|
111
|
+
runtime.worker_view!
|
|
112
|
+
runtime
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# A fork gets routing and child-loop ownership, but must never persist
|
|
116
|
+
# the parent's checkpoint generation. The parent owns final receipts.
|
|
117
|
+
def worker_view!
|
|
118
|
+
@worker_copy = true
|
|
119
|
+
@trace = false
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def worker_provenance!(opts = {})
|
|
123
|
+
@worker_provenance = opts.fetch(:rows).to_h.transform_keys(&:to_s)
|
|
124
|
+
end
|
|
125
|
+
|
|
90
126
|
# Only host observations enter this store, never parsed tool stdout.
|
|
91
127
|
def observe!(opts = {})
|
|
92
128
|
@mutex.synchronize do
|
|
93
|
-
opts.fetch(:observations).each
|
|
129
|
+
opts.fetch(:observations).each do |path, row|
|
|
130
|
+
child = @worker_provenance&.dig(path.to_s)
|
|
131
|
+
row = row.merge(child.slice(:child_request_id, :child_turn_id, :child_provenance)) if child && child[:artifact_turn_id] == @contract[:id] && child[:sha256] == row[:sha256] && child[:stat] == row[:stat] && child[:child_request_id]
|
|
132
|
+
@observations[path.to_s] = row
|
|
133
|
+
end
|
|
94
134
|
checkpoint!
|
|
95
135
|
end
|
|
96
136
|
end
|
|
@@ -295,8 +335,38 @@ module PWN
|
|
|
295
335
|
observed >= @started_at && observed <= Time.now && (!row[:max_age] || Time.now - observed <= row[:max_age].to_f)
|
|
296
336
|
end
|
|
297
337
|
|
|
338
|
+
# Uncertainty is a recovery state, not another instruction to rewrite
|
|
339
|
+
# artifacts forever. Retain the replay fence and return actionable IDs.
|
|
340
|
+
def blocker
|
|
341
|
+
last = events.reverse.find { |row| row[:phase] == 'end' }
|
|
342
|
+
return "Tool execution blocked before handler start: #{last[:diagnostic]}. Enable the required kernel protection or choose a supported host; no unsafe fallback was used." if last && last[:state] == 'rejected'
|
|
343
|
+
|
|
344
|
+
rows = blocking_effects
|
|
345
|
+
return nil if rows.empty?
|
|
346
|
+
|
|
347
|
+
details = rows.map { |row| "#{row[:tool]} action=#{row[:action_id]} payload_sha256=#{row[:payload_sha256]}" }
|
|
348
|
+
"Uncertain side effect: #{details.join('; ')}. Do not replay or rewrite blindly. " \
|
|
349
|
+
'Inspect the exact affected target and request operator-approved reconciliation; unrelated reads or new artifacts cannot resolve this action.'
|
|
350
|
+
end
|
|
351
|
+
|
|
298
352
|
def unmet
|
|
299
|
-
|
|
353
|
+
blocking_effects.any? ? ['uncertain_side_effect'] : []
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# A finished search is an observation. Only a write, a store, or a
|
|
357
|
+
# named remote target can block the answer. Replay of an interrupted
|
|
358
|
+
# command stays denied even when it does not block.
|
|
359
|
+
def blocking_effects
|
|
360
|
+
@mutex.synchronize do
|
|
361
|
+
@effects.select { |_key, state| state == 'uncertain' }.keys.filter_map do |key|
|
|
362
|
+
row = @events.reverse.find { |item| item[:payload_sha256] == key && item[:phase] == 'start' }
|
|
363
|
+
row if row && durable_effect?(effect: row[:effect], target: row[:target])
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def durable_effect?(opts = {})
|
|
369
|
+
%w[write store].include?(opts[:effect].to_s) || !opts[:target].to_s.empty?
|
|
300
370
|
end
|
|
301
371
|
|
|
302
372
|
# A started mutation stays uncertain across interruption or process death.
|
|
@@ -320,15 +390,33 @@ module PWN
|
|
|
320
390
|
end
|
|
321
391
|
return { success: false, error: 'uncertain_side_effect', hint: 'Inspect the target before any operator-approved recovery; this mutation was not replayed.' } if denied
|
|
322
392
|
|
|
393
|
+
before = TurnFinalizer.artifact_snapshot(paths: @contract[:required_artifacts])
|
|
323
394
|
begin
|
|
324
395
|
result = provider_attempt(&)
|
|
325
|
-
failed = result.is_a?(Hash) && (result[:error] || result['error'] || result[:success] == false || result['success'] == false)
|
|
326
|
-
|
|
396
|
+
failed = result.is_a?(Hash) && (result[:error] || result['error'] || result[:success] == false || result['success'] == false || result[:ok] == false || (result.key?(:exit) && result[:exit] != 0))
|
|
397
|
+
timed_out = result.is_a?(Hash) && (result[:error] || result['error']).to_s.match?(/timeout/i)
|
|
398
|
+
# A returned timeout still may have applied. Only a finished
|
|
399
|
+
# command (exit/stdout, no timeout) is an observation.
|
|
400
|
+
observed = result.is_a?(Hash) && !timed_out && (result.key?(:exit) || result.key?('exit') || result.key?(:stdout) || result.key?('stdout'))
|
|
401
|
+
unless failed
|
|
402
|
+
# Classification is only scheduling advice: successful arbitrary
|
|
403
|
+
# code may write files even when classified eval/read. The host
|
|
404
|
+
# delta, not payload keywords or stdout, proves the actual write.
|
|
405
|
+
TurnFinalizer.observe_artifacts(paths: @contract[:required_artifacts], before: before,
|
|
406
|
+
effect: :write, success: true, action_id: opts[:action_id], tool: opts[:name])
|
|
407
|
+
end
|
|
408
|
+
state = if !failed
|
|
409
|
+
'completed'
|
|
410
|
+
elsif timed_out || !observed
|
|
327
411
|
mutation ? 'uncertain' : 'failed'
|
|
328
412
|
else
|
|
329
|
-
'
|
|
413
|
+
'failed'
|
|
330
414
|
end
|
|
331
415
|
result
|
|
416
|
+
rescue PWN::Plugins::AISandbox::NotStarted => e
|
|
417
|
+
state = 'rejected'
|
|
418
|
+
action[:diagnostic] = e.message
|
|
419
|
+
{ success: false, error: e.message, execution_state: 'not_started' }
|
|
332
420
|
ensure
|
|
333
421
|
@mutex.synchronize do
|
|
334
422
|
state ||= mutation ? 'uncertain' : 'interrupted'
|
|
@@ -474,18 +562,35 @@ module PWN
|
|
|
474
562
|
end
|
|
475
563
|
|
|
476
564
|
def checkpoint!
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
565
|
+
return if @worker_copy
|
|
566
|
+
|
|
567
|
+
PWN::Plugins::AISandbox.control_directory(path: File.dirname(@checkpoint_path)) do |directory|
|
|
568
|
+
path = File.join(directory, File.basename(@checkpoint_path))
|
|
569
|
+
File.open("#{path}.lock", File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock|
|
|
570
|
+
raise IOError, 'checkpoint lock must be a singly linked regular file' unless lock.stat.file? && lock.stat.nlink == 1
|
|
571
|
+
|
|
572
|
+
lock.flock(File::LOCK_EX)
|
|
573
|
+
current = checkpoint_generation(path: path)
|
|
574
|
+
raise IOError, 'stale request checkpoint: reload before proceeding' unless current == @generation
|
|
575
|
+
|
|
576
|
+
@generation += 1
|
|
577
|
+
write_checkpoint!(path: path)
|
|
578
|
+
end
|
|
579
|
+
end
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
def checkpoint_generation(opts = {})
|
|
583
|
+
File.open(opts.fetch(:path), File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
|
|
584
|
+
raise IOError, 'checkpoint must be a singly linked regular file' unless file.stat.file? && file.stat.nlink == 1
|
|
585
|
+
|
|
586
|
+
JSON.parse(file.read).fetch('generation', 0)
|
|
485
587
|
end
|
|
588
|
+
rescue Errno::ENOENT
|
|
589
|
+
0
|
|
486
590
|
end
|
|
487
591
|
|
|
488
|
-
def write_checkpoint!
|
|
592
|
+
def write_checkpoint!(opts = {})
|
|
593
|
+
path = opts.fetch(:path)
|
|
489
594
|
rows = @observations.transform_values do |row|
|
|
490
595
|
row.merge(head: Base64.strict_encode64(row[:head]), tail: Base64.strict_encode64(row[:tail]))
|
|
491
596
|
end
|
|
@@ -493,14 +598,13 @@ module PWN
|
|
|
493
598
|
request: @request, amendments: @amendments, cwd: @cwd, started_at: @started_at.iso8601(9),
|
|
494
599
|
observations: rows, outcome: @outcome, events: @events, effects: @effects, evidence: @evidence,
|
|
495
600
|
enabled_toolsets: @enabled_toolsets, verification_contract: @verification_contract }
|
|
496
|
-
|
|
497
|
-
temp = "#{@checkpoint_path}.#{SecureRandom.hex(8)}.tmp"
|
|
601
|
+
temp = "#{path}.#{SecureRandom.hex(8)}.tmp"
|
|
498
602
|
File.open(temp, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
|
|
499
603
|
file.write(JSON.generate(PWN::AI::Agent::Result.utf8_value(value: data)))
|
|
500
604
|
file.flush
|
|
501
605
|
file.fsync
|
|
502
606
|
end
|
|
503
|
-
File.rename(temp,
|
|
607
|
+
File.rename(temp, path)
|
|
504
608
|
ensure
|
|
505
609
|
File.unlink(temp) if temp && File.exist?(temp)
|
|
506
610
|
end
|
data/lib/pwn/ai/agent/reward.rb
CHANGED
|
@@ -1079,10 +1079,12 @@ module PWN
|
|
|
1079
1079
|
reasons[why] += 1
|
|
1080
1080
|
end
|
|
1081
1081
|
end
|
|
1082
|
+
bak = nil
|
|
1082
1083
|
unless dry
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1084
|
+
PWN::Backups.transaction do
|
|
1085
|
+
bak = PWN::Backups.capture(path: path, label: 'preferences')
|
|
1086
|
+
File.open(path, 'w') { |f| kept.each { |r| f.puts(JSON.generate(r)) } }
|
|
1087
|
+
end
|
|
1086
1088
|
end
|
|
1087
1089
|
{
|
|
1088
1090
|
before: raw.length,
|
|
@@ -85,6 +85,8 @@ module PWN
|
|
|
85
85
|
end
|
|
86
86
|
|
|
87
87
|
public_class_method def self.consolidate_file(opts = {})
|
|
88
|
+
return PWN::Backups.transaction { consolidate_file(opts) } if opts[:dry_run] == false && !PWN::Backups.active?
|
|
89
|
+
|
|
88
90
|
path = File.expand_path(opts.fetch(:path))
|
|
89
91
|
raise ArgumentError, 'symlink skills are not writable consolidation targets' if File.symlink?(path)
|
|
90
92
|
|
|
@@ -99,8 +101,7 @@ module PWN
|
|
|
99
101
|
lock.flock(File::LOCK_EX)
|
|
100
102
|
raise ArgumentError, 'skill changed during consolidation' unless File.read(path) == original && !File.symlink?(path)
|
|
101
103
|
|
|
102
|
-
backup =
|
|
103
|
-
File.open(backup, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |file| file.write(original) } unless File.exist?(backup)
|
|
104
|
+
backup = PWN::Backups.capture(path: path, label: 'skill-consolidation')
|
|
104
105
|
tmp = "#{path}.#{Process.pid}.tmp"
|
|
105
106
|
File.open(tmp, File::WRONLY | File::CREAT | File::EXCL, File.stat(path).mode & 0o777) do |file|
|
|
106
107
|
file.write(result[:content])
|
|
@@ -80,6 +80,8 @@ module PWN
|
|
|
80
80
|
# skills_root: 'optional - local skills root; default ~/.pwn/skills'
|
|
81
81
|
# )
|
|
82
82
|
public_class_method def self.decide(opts = {})
|
|
83
|
+
return PWN::Backups.transaction { decide(opts) } unless PWN::Backups.active?
|
|
84
|
+
|
|
83
85
|
digest = opts[:source_sha256].to_s
|
|
84
86
|
decision = opts[:decision].to_s
|
|
85
87
|
root = File.realpath(opts[:skills_root] || File.join(Dir.home, '.pwn', 'skills'))
|
|
@@ -202,8 +204,7 @@ module PWN
|
|
|
202
204
|
original = File.binread(path)
|
|
203
205
|
raise 'skill source changed' unless Digest::SHA256.hexdigest(original) == proposal[:source_sha256]
|
|
204
206
|
|
|
205
|
-
backup =
|
|
206
|
-
File.open(backup, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) { |file| file.write(original) } unless File.exist?(backup)
|
|
207
|
+
backup = PWN::Backups.capture(path: path, label: 'skill-review')
|
|
207
208
|
raise 'backup changed' unless !File.symlink?(backup) && File.binread(backup) == original
|
|
208
209
|
|
|
209
210
|
content = "#{original}\n\n## Reviewed procedure\n\n#{procedure[:text]}\n"
|
|
@@ -330,14 +331,15 @@ module PWN
|
|
|
330
331
|
end
|
|
331
332
|
|
|
332
333
|
private_class_method def self.apply_update(opts = {})
|
|
334
|
+
lock = Learning.send(:learning_lock)
|
|
335
|
+
return PWN::Backups.transaction { apply_update(opts) } unless PWN::Backups.active?
|
|
336
|
+
|
|
333
337
|
proposal = opts[:proposal]
|
|
334
338
|
path = proposal[:path].to_s
|
|
335
339
|
return finish(proposal.merge(action: 'refused', reason: 'skill path missing')) unless File.file?(path) && !File.symlink?(path)
|
|
336
340
|
|
|
337
341
|
original = File.read(path)
|
|
338
|
-
|
|
339
|
-
backup = "#{path}.#{digest}.bak"
|
|
340
|
-
File.open(backup, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |file| file.write(original) } unless File.exist?(backup)
|
|
342
|
+
backup = PWN::Backups.capture(path: path, label: 'skill-review')
|
|
341
343
|
signature = (opts[:evidence] || {})[:signature].to_s
|
|
342
344
|
lesson = "#{signature}\n#{opts[:procedure][:text]}"
|
|
343
345
|
written = Learning.update_skill(name: proposal[:name], lesson: lesson, query: proposal[:request], pwn_skills_path: opts[:skills_root])
|
|
@@ -349,6 +351,8 @@ module PWN
|
|
|
349
351
|
|
|
350
352
|
PWN::Config.load_skills(pwn_skills_path: opts[:skills_root]) if defined?(PWN::Config) && PWN::Config.respond_to?(:load_skills)
|
|
351
353
|
finish(proposal.merge(action: 'applied', backup: backup))
|
|
354
|
+
ensure
|
|
355
|
+
Learning.send(:learning_unlock, lock: lock)
|
|
352
356
|
end
|
|
353
357
|
|
|
354
358
|
private_class_method def self.finish(opts = {})
|
data/lib/pwn/ai/agent/swarm.rb
CHANGED
|
@@ -89,6 +89,8 @@ module PWN
|
|
|
89
89
|
# )
|
|
90
90
|
|
|
91
91
|
public_class_method def self.spawn(opts = {})
|
|
92
|
+
return PWN::Plugins::AISandbox.swarm_control(operation: 'spawn', args: opts) if Thread.current[:pwn_swarm_controller]
|
|
93
|
+
|
|
92
94
|
name = opts[:name].to_s
|
|
93
95
|
raise ArgumentError, 'name is required' if name.strip.empty?
|
|
94
96
|
raise ArgumentError, 'role is required' if opts[:role].to_s.strip.empty?
|
|
@@ -120,12 +122,19 @@ module PWN
|
|
|
120
122
|
public_class_method def self.retire(opts = {})
|
|
121
123
|
name = opts[:name].to_s
|
|
122
124
|
sid = opts[:swarm_id].to_s
|
|
125
|
+
continuation = nil
|
|
126
|
+
if opts[:continuation]
|
|
127
|
+
raise ArgumentError, 'Continuation retirement requires local and swarm_id' unless opts[:local] == true && !sid.empty?
|
|
128
|
+
|
|
129
|
+
require File.expand_path('../../../../etc/default_cron/session_cleanup', __dir__)
|
|
130
|
+
continuation = PWN::SessionCleanup.retire_continuation(root: SWARM_ROOT, swarm_id: sid, name: name)
|
|
131
|
+
end
|
|
123
132
|
if opts[:local] == true && !sid.empty?
|
|
124
133
|
path = File.join(SWARM_ROOT, sid, 'agents.yml')
|
|
125
134
|
all = load_personas_file(path: path)
|
|
126
135
|
gone = all.delete(name.to_sym)
|
|
127
136
|
File.write(path, YAML.dump(deep_stringify(hash: all))) if gone
|
|
128
|
-
return { name: name, removed: !gone.nil?, scope: :swarm }
|
|
137
|
+
return { name: name, removed: !gone.nil?, scope: :swarm, continuation: continuation }
|
|
129
138
|
end
|
|
130
139
|
|
|
131
140
|
all = personas
|
|
@@ -236,6 +245,8 @@ module PWN
|
|
|
236
245
|
empty_tools = false
|
|
237
246
|
prior_id = Thread.current[:pwn_swarm_id]
|
|
238
247
|
prior_honesty = Thread.current[:pwn_swarm_honesty]
|
|
248
|
+
return PWN::Plugins::AISandbox.swarm_control(operation: 'ask', args: opts) if Thread.current[:pwn_swarm_controller]
|
|
249
|
+
|
|
239
250
|
sid = opts[:swarm_id] || create(topic: opts[:request].to_s[0, 60])[:swarm_id]
|
|
240
251
|
persona = opts[:persona] || personas(swarm_id: sid)[name.to_sym]
|
|
241
252
|
raise ArgumentError, "unknown persona: #{name} (see #{AGENTS_FILE})" unless persona
|
|
@@ -414,21 +425,28 @@ module PWN
|
|
|
414
425
|
sid = opts[:swarm_id].to_s
|
|
415
426
|
name = opts[:name].to_s
|
|
416
427
|
map_path = File.join(SWARM_ROOT, sid, 'personas.json')
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
return map[name]
|
|
421
|
-
end
|
|
428
|
+
require File.expand_path('../../../../etc/default_cron/session_cleanup', __dir__)
|
|
429
|
+
PWN::SessionCleanup.with_continuation_lock(root: File.dirname(SWARM_ROOT)) do
|
|
430
|
+
raise IOError, 'Unsafe continuation map' if File.symlink?(map_path)
|
|
422
431
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
+
map = File.exist?(map_path) ? JSON.parse(File.read(map_path)) : {}
|
|
433
|
+
raise IOError, 'Unknown continuation map' unless map.is_a?(Hash)
|
|
434
|
+
|
|
435
|
+
if map[name]
|
|
436
|
+
opts[:leases] << PWN::Sessions.activate(session_id: map[name]) if opts[:leases]
|
|
437
|
+
next map[name]
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
sess = PWN::Sessions.create(
|
|
441
|
+
title: "swarm:#{sid} persona:#{name}",
|
|
442
|
+
source: 'pwn-ai-swarm',
|
|
443
|
+
active: !opts[:leases].nil?
|
|
444
|
+
)
|
|
445
|
+
opts[:leases] << sess[:lease] if opts[:leases]
|
|
446
|
+
map[name] = sess[:id]
|
|
447
|
+
PWN::SessionCleanup.publish(map_path, JSON.pretty_generate(map))
|
|
448
|
+
sess[:id]
|
|
449
|
+
end
|
|
432
450
|
end
|
|
433
451
|
|
|
434
452
|
private_class_method def self.build_persona_prompt(opts = {})
|
|
@@ -759,7 +777,8 @@ module PWN
|
|
|
759
777
|
#{self}.retire(
|
|
760
778
|
name: 'required - persona name',
|
|
761
779
|
swarm_id: 'optional - swarm directory when local is true',
|
|
762
|
-
local: 'optional - true retires only that swarm registry'
|
|
780
|
+
local: 'optional - true retires only that swarm registry',
|
|
781
|
+
continuation: 'optional - explicitly retire the inactive saved conversation association; requires local and swarm_id'
|
|
763
782
|
)
|
|
764
783
|
|
|
765
784
|
# Swarm lifecycle & bus
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pwn/ai/agent/registry'
|
|
4
|
+
|
|
5
|
+
PWN::AI::Agent::Registry.register(
|
|
6
|
+
name: 'browser_act',
|
|
7
|
+
toolset: 'pwn',
|
|
8
|
+
schema: {
|
|
9
|
+
name: 'browser_act',
|
|
10
|
+
description: 'Drive a live browser toward a goal. When Open WebUI has a Jev model, Jev picks each CLICK, TYPE_TEXT, SELECT, scroll, WAIT, DONE, or BLOCKED target from visible controls. The active pwn-ai model writes field text only. If Jev is not configured, the call reports unavailable and does not invent clicks.',
|
|
11
|
+
parameters: {
|
|
12
|
+
type: 'object',
|
|
13
|
+
properties: {
|
|
14
|
+
goal: { type: 'string', description: 'Natural-language goal for the current page.' },
|
|
15
|
+
url: { type: 'string', description: 'Optional HTTP(S) URL to open before the first observation.' },
|
|
16
|
+
steps: { type: 'integer', description: 'Maximum decision cycles, 1 to 60. Defaults to 12.' },
|
|
17
|
+
browser_type: { type: 'string', description: 'Browser type when no browser_obj is supplied. Defaults to headless.' }
|
|
18
|
+
},
|
|
19
|
+
required: ['goal']
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
handler: lambda { |args|
|
|
23
|
+
args = args.transform_keys(&:to_sym)
|
|
24
|
+
owned = args[:browser_obj].nil?
|
|
25
|
+
browser_obj = args[:browser_obj] || PWN::Plugins::TransparentBrowser.open(browser_type: (args[:browser_type] || :headless).to_sym)
|
|
26
|
+
begin
|
|
27
|
+
PWN::Plugins::TransparentBrowser.act(browser_obj: browser_obj, goal: args[:goal], url: args[:url], steps: args[:steps])
|
|
28
|
+
ensure
|
|
29
|
+
PWN::Plugins::TransparentBrowser.close(browser_obj: browser_obj) if owned && browser_obj
|
|
30
|
+
end
|
|
31
|
+
}
|
|
32
|
+
)
|
|
@@ -7,7 +7,7 @@ PWN::AI::Agent::Registry.register(
|
|
|
7
7
|
toolset: 'pwn',
|
|
8
8
|
schema: {
|
|
9
9
|
name: 'loot_query',
|
|
10
|
-
description: 'Query
|
|
10
|
+
description: 'Query engagement-scoped encrypted loot metadata without decrypting secrets. Store/ingest/offer can select YubiKey hardware protection; hardware access failures never fall back to software. BasicAuth.encode(host:) auto-offers matching secrets when a service prompts for auth.',
|
|
11
11
|
parameters: {
|
|
12
12
|
type: 'object',
|
|
13
13
|
properties: {
|
|
@@ -23,6 +23,16 @@ PWN::AI::Agent::Registry.register(
|
|
|
23
23
|
username: { type: 'string' },
|
|
24
24
|
where: { type: 'string' },
|
|
25
25
|
source: { type: 'string' },
|
|
26
|
+
yubikey: {
|
|
27
|
+
type: 'object',
|
|
28
|
+
description: 'Non-secret selector for an already provisioned HMAC-SHA1 slot. Omit for the existing backend; an empty object selects hardware defaults.',
|
|
29
|
+
properties: {
|
|
30
|
+
slot: { type: 'integer', enum: [1, 2] },
|
|
31
|
+
serial: { type: 'string', pattern: '^[0-9]+$' },
|
|
32
|
+
timeout: { type: 'number', exclusiveMinimum: 0 }
|
|
33
|
+
},
|
|
34
|
+
additionalProperties: false
|
|
35
|
+
},
|
|
26
36
|
label: { type: 'string' }
|
|
27
37
|
}
|
|
28
38
|
}
|
|
@@ -7,7 +7,7 @@ PWN::AI::Agent::Registry.register(
|
|
|
7
7
|
toolset: 'pwn',
|
|
8
8
|
schema: {
|
|
9
9
|
name: 'recon_run',
|
|
10
|
-
description: 'Run selected recon modules against one hostname/IP, persist normalized assets with stable IDs and evidence paths, and ingest the asset model for retrieval. Scanner observations are not proven findings.',
|
|
10
|
+
description: 'Run selected recon modules against one hostname/IP, persist normalized assets with stable IDs and evidence paths, and ingest the asset model for retrieval. Optional YubiKey selection protects harvested loot, not raw recon evidence. Scanner observations are not proven findings.',
|
|
11
11
|
parameters: {
|
|
12
12
|
type: 'object',
|
|
13
13
|
properties: {
|
|
@@ -15,6 +15,16 @@ PWN::AI::Agent::Registry.register(
|
|
|
15
15
|
modules: { type: 'array', minItems: 1, items: { type: 'string', enum: %w[nmap banner tls subfinder nuclei] } },
|
|
16
16
|
ports: { type: 'array', items: { type: 'integer', minimum: 1, maximum: 65_535 } },
|
|
17
17
|
engagement_id: { type: 'string' },
|
|
18
|
+
yubikey: {
|
|
19
|
+
type: 'object',
|
|
20
|
+
description: 'Non-secret selector for hardware-encrypted harvested loot using an already provisioned HMAC-SHA1 slot. No software fallback on hardware failure.',
|
|
21
|
+
properties: {
|
|
22
|
+
slot: { type: 'integer', enum: [1, 2] },
|
|
23
|
+
serial: { type: 'string', pattern: '^[0-9]+$' },
|
|
24
|
+
timeout: { type: 'number', exclusiveMinimum: 0 }
|
|
25
|
+
},
|
|
26
|
+
additionalProperties: false
|
|
27
|
+
},
|
|
18
28
|
timeout: { type: 'number', minimum: 0, maximum: 300 },
|
|
19
29
|
ingest: { type: 'boolean', default: true }
|
|
20
30
|
},
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pwn/ai/agent/registry'
|
|
4
|
+
|
|
5
|
+
PWN::AI::Agent::Registry.register(
|
|
6
|
+
name: 'workspace_tool',
|
|
7
|
+
toolset: 'pwn',
|
|
8
|
+
schema: {
|
|
9
|
+
name: 'workspace_tool',
|
|
10
|
+
description: 'Run one provider-hosted tool. Open WebUI workspace tools, including Ask Jev, execute on that server and are not mixed into the client tools array. Other engines expose only their own hosted tools, such as web_search, google_search, or x_search. Ollama has none. Omit name to list the Open WebUI workspace.',
|
|
11
|
+
parameters: {
|
|
12
|
+
type: 'object',
|
|
13
|
+
properties: {
|
|
14
|
+
name: { type: 'string', description: 'Workspace tool name, such as Ask Jev, or a hosted tool name such as web_search.' },
|
|
15
|
+
request: { type: 'string', description: 'Question or state to send with the tool. Required unless name is omitted to list Open WebUI tools.' },
|
|
16
|
+
engine: { type: 'string', description: 'Optional provider. Ask Jev uses Open WebUI when that provider is configured, even if another model is active.' }
|
|
17
|
+
},
|
|
18
|
+
required: []
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
handler: lambda { |args|
|
|
22
|
+
args = args.transform_keys(&:to_sym)
|
|
23
|
+
if args[:name].to_s.strip.empty?
|
|
24
|
+
PWN::AI::ServerTools.list(engine: :openwebui)
|
|
25
|
+
else
|
|
26
|
+
PWN::AI::ServerTools.call(name: args[:name], request: args[:request], engine: args[:engine])
|
|
27
|
+
end
|
|
28
|
+
}
|
|
29
|
+
)
|
|
@@ -209,10 +209,14 @@ module PWN
|
|
|
209
209
|
request.to_enum(:scan, %r{["'`]((?:~/|/|\./|\.\./)[^"'`\n]+|[^"'`\n]+\.[A-Za-z0-9]+)["'`]|(?<![\w:/])((?:~/|/|\./|\.\./)[\w.+/-]+|[\w+-]+\.[A-Za-z0-9]+)}).each do
|
|
210
210
|
match = Regexp.last_match
|
|
211
211
|
literal = match[1] || match[2].sub(/[.,;:]+\z/, '')
|
|
212
|
+
# Unquoted dotted initials followed by a period are prose (i.e.,
|
|
213
|
+
# e.g., U.S.), not a filename. Quoted literals remain authoritative.
|
|
214
|
+
next if !match[1] && literal.match?(/\A(?:[A-Za-z]\.)+[A-Za-z]\z/) && request[match.end(0)] == '.'
|
|
215
|
+
|
|
212
216
|
prefix = request[previous_end...match.begin(0)]
|
|
213
217
|
previous_end = match.end(0)
|
|
214
|
-
intent = prefix.scan(/\b(write|save|store|output|export|create|update|modify|edit|generate|put|produce|deliver|read|inspect|review|analy[sz]e|summari[sz]e|from|using)\b/i).flatten.last&.downcase || intent
|
|
215
|
-
next unless %w[write save store output export create update modify edit generate put produce deliver].include?(intent)
|
|
218
|
+
intent = prefix.scan(/\b(write|save|store|output|export|create|update|modify|edit|generate|put|produce|deliver|into|mv|move|copy|cp|rename|to|read|inspect|review|analy[sz]e|summari[sz]e|from|using)\b/i).flatten.last&.downcase || intent
|
|
219
|
+
next unless %w[write save store output export create update modify edit generate put produce deliver into mv move copy cp rename to].include?(intent)
|
|
216
220
|
|
|
217
221
|
paths << File.expand_path(literal, opts[:cwd] || Dir.pwd)
|
|
218
222
|
end
|
data/lib/pwn/ai/agent.rb
CHANGED
|
@@ -48,6 +48,7 @@ module PWN
|
|
|
48
48
|
autoload :TurnFinalizer, 'pwn/ai/agent/turn_finalizer'
|
|
49
49
|
autoload :Engagement, 'pwn/ai/agent/engagement'
|
|
50
50
|
autoload :Mission, 'pwn/ai/agent/mission'
|
|
51
|
+
autoload :MissionNamespace, 'pwn/ai/agent/mission_namespace'
|
|
51
52
|
autoload :OpenGoal, 'pwn/ai/agent/open_goal'
|
|
52
53
|
autoload :PromptCache, 'pwn/ai/agent/prompt_cache'
|
|
53
54
|
|
data/lib/pwn/ai/anthropic.rb
CHANGED
|
@@ -163,7 +163,6 @@ module PWN
|
|
|
163
163
|
# bearer lives in-session only.
|
|
164
164
|
private_class_method def self.persist_oauth_to_vault(opts = {})
|
|
165
165
|
oauth = opts[:oauth]
|
|
166
|
-
return false if Thread.current[:pwn_request_runtime]
|
|
167
166
|
return false unless oauth.is_a?(Hash)
|
|
168
167
|
return false unless real_config_value?(value: oauth[:bearer_token])
|
|
169
168
|
|
|
@@ -178,27 +177,13 @@ module PWN
|
|
|
178
177
|
dec_path = dec_path.to_s.strip
|
|
179
178
|
dec_path = "#{env_path}.decryptor" if dec_path.empty?
|
|
180
179
|
|
|
181
|
-
unless File.exist?(env_path)
|
|
180
|
+
unless File.exist?(env_path)
|
|
182
181
|
puts '[*] INFO: Anthropic OAuth tokens updated in this session only; ' \
|
|
183
|
-
"persistence to #{env_path} skipped (missing
|
|
184
|
-
"#{" at #{dec_path}" unless dec_path.empty?})."
|
|
182
|
+
"persistence to #{env_path} skipped (missing vault)."
|
|
185
183
|
return false
|
|
186
184
|
end
|
|
187
185
|
|
|
188
|
-
|
|
189
|
-
key = decryptor.is_a?(Hash) ? decryptor[:key] : nil
|
|
190
|
-
iv = decryptor.is_a?(Hash) ? decryptor[:iv] : nil
|
|
191
|
-
unless real_config_value?(value: key) && real_config_value?(value: iv)
|
|
192
|
-
puts '[*] INFO: Anthropic OAuth tokens updated in this session only; ' \
|
|
193
|
-
"persistence to #{env_path} skipped (decryptor at #{dec_path} " \
|
|
194
|
-
'has no usable key/iv).'
|
|
195
|
-
return false
|
|
196
|
-
end
|
|
197
|
-
|
|
198
|
-
PWN::Plugins::Vault.decrypt(file: env_path, key: key, iv: iv)
|
|
199
|
-
begin
|
|
200
|
-
cfg = YAML.load_file(env_path, symbolize_names: true)
|
|
201
|
-
cfg = {} unless cfg.is_a?(Hash)
|
|
186
|
+
PWN::Plugins::Vault.update(file: env_path, decryptor_file: dec_path) do |cfg|
|
|
202
187
|
cfg[:ai] = {} unless cfg[:ai].is_a?(Hash)
|
|
203
188
|
cfg[:ai][:anthropic] = {} unless cfg[:ai][:anthropic].is_a?(Hash)
|
|
204
189
|
vault_oauth = cfg[:ai][:anthropic][:oauth]
|
|
@@ -207,19 +192,11 @@ module PWN
|
|
|
207
192
|
vault_oauth[:bearer_token] = oauth[:bearer_token]
|
|
208
193
|
vault_oauth[:refresh_token] = oauth[:refresh_token] if real_config_value?(value: oauth[:refresh_token])
|
|
209
194
|
vault_oauth[:expires_at] = oauth[:expires_at] if oauth.key?(:expires_at) && !oauth[:expires_at].nil?
|
|
210
|
-
|
|
211
|
-
# Match PWN::Config.default_env YAML style (string keys, no leading ':').
|
|
212
|
-
yaml_env = YAML.dump(cfg).gsub(/^(\s*):/, '\1')
|
|
213
|
-
File.write(env_path, yaml_env)
|
|
214
|
-
File.chmod(0o600, env_path)
|
|
215
|
-
ensure
|
|
216
|
-
# Always re-encrypt with the IDENTICAL key + iv — never rotate.
|
|
217
|
-
PWN::Plugins::Vault.encrypt(file: env_path, key: key, iv: iv)
|
|
218
195
|
end
|
|
219
196
|
|
|
220
197
|
true
|
|
221
198
|
rescue StandardError => e
|
|
222
|
-
warn "[!] Anthropic OAuth vault persistence failed (session tokens still updated): #{e.class}
|
|
199
|
+
warn "[!] Anthropic OAuth vault persistence failed (session tokens still updated): #{e.class}"
|
|
223
200
|
false
|
|
224
201
|
end
|
|
225
202
|
|
|
@@ -297,14 +274,11 @@ module PWN
|
|
|
297
274
|
opts[:refresh_token] = refresh_token if refresh_token
|
|
298
275
|
opts[:expires_at] = Time.now.to_i + data['expires_in'].to_i if data['expires_in']
|
|
299
276
|
|
|
277
|
+
sync_oauth_into_env(oauth: opts)
|
|
278
|
+
persisted = persist_oauth_to_vault(oauth: opts)
|
|
300
279
|
puts "\n[*] SUCCESS: Anthropic OAuth bearer obtained via authorization_code + PKCE."
|
|
301
280
|
puts ' Cached in-memory for this pwn / pwn-ai process.'
|
|
302
|
-
puts ''
|
|
303
|
-
puts ' TO MAKE THIS PERMANENT (recommended -- one-time), store via pwn-vault:'
|
|
304
|
-
puts " ai.anthropic.oauth.refresh_token = #{refresh_token}" if refresh_token
|
|
305
|
-
puts " ai.anthropic.oauth.bearer_token = #{access_token}"
|
|
306
|
-
puts ' On future runs the refresh_token alone is enough -- PWN::AI::Anthropic will'
|
|
307
|
-
puts ' silently exchange it for a fresh access_token (no browser, no prompt).'
|
|
281
|
+
puts(persisted ? ' Saved to the encrypted configuration vault.' : ' Session only: encrypted vault persistence unavailable.')
|
|
308
282
|
puts ''
|
|
309
283
|
|
|
310
284
|
access_token
|
|
@@ -614,6 +588,7 @@ module PWN
|
|
|
614
588
|
end
|
|
615
589
|
http_body[:tool_choice] = anth_tool_choice(choice: opts[:tool_choice], model: model) if opts[:tool_choice]
|
|
616
590
|
end
|
|
591
|
+
http_body = PWN::AI::ServerTools.attach(engine: :anthropic, http_body: http_body, request: opts)
|
|
617
592
|
effort = opts[:reasoning_effort].to_s
|
|
618
593
|
if !effort.empty? && effort != 'none' && reasoning_efforts(model: model)[:efforts].include?(effort)
|
|
619
594
|
http_body[:output_config] = { effort: effort }
|