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.
Files changed (254) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/Gemfile +6 -6
  4. data/README.md +37 -0
  5. data/bin/pwn +2 -1
  6. data/bin/pwn_web_cache_deception +35 -7
  7. data/build_gem.sh +1 -1
  8. data/documentation/AI-Integration.md +7 -5
  9. data/documentation/Agent-Tool-Registry.md +2 -2
  10. data/documentation/Backup-Retention.md +69 -15
  11. data/documentation/Configuration.md +10 -6
  12. data/documentation/Contributing.md +1 -1
  13. data/documentation/Cron.md +2 -2
  14. data/documentation/Diagrams.md +2 -2
  15. data/documentation/General-PWN-Usage.md +4 -3
  16. data/documentation/Harness-Evaluation.md +1 -1
  17. data/documentation/Home.md +6 -5
  18. data/documentation/How-PWN-Works.md +6 -5
  19. data/documentation/Installation.md +12 -9
  20. data/documentation/Mission-Cancellation.md +134 -0
  21. data/documentation/Operability.md +18 -0
  22. data/documentation/Persistence.md +7 -5
  23. data/documentation/Plugin-Degradation.md +14 -0
  24. data/documentation/Plugins.md +3 -2
  25. data/documentation/Policy-Benchmark.md +32 -33
  26. data/documentation/Runtime-Source-Protection.md +162 -0
  27. data/documentation/Transparent-Browser.md +11 -0
  28. data/documentation/Troubleshooting.md +3 -2
  29. data/documentation/What-is-PWN.md +2 -2
  30. data/documentation/YubiKey-Vaults.md +185 -0
  31. data/documentation/YubiKey.md +218 -0
  32. data/documentation/diagrams/cron-scheduling.svg +130 -130
  33. data/documentation/diagrams/dot/cron-scheduling.dot +3 -3
  34. data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
  35. data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
  36. data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +1 -1
  37. data/documentation/diagrams/dot/reinforcement-learning.dot +2 -2
  38. data/documentation/diagrams/persistence-filesystem.svg +172 -142
  39. data/documentation/diagrams/plugin-ecosystem.svg +64 -64
  40. data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +1 -1
  41. data/documentation/diagrams/reinforcement-learning.svg +89 -89
  42. data/documentation/pwn-ai-Agent.md +10 -4
  43. data/etc/default_cron/session_cleanup.rb +43 -27
  44. data/etc/default_skills/pwn/ai/agent/mission_namespace/SKILL.md +21 -0
  45. data/etc/default_skills/pwn/ai/agent/tools/browser_act/SKILL.md +45 -0
  46. data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +27 -26
  47. data/etc/default_skills/pwn/ai/agent/tools/recon_run/SKILL.md +25 -15
  48. data/etc/default_skills/pwn/ai/agent/tools/workspace_tool/SKILL.md +24 -0
  49. data/etc/default_skills/pwn/ai/server_tools/SKILL.md +25 -0
  50. data/etc/default_skills/pwn/backups/SKILL.md +54 -0
  51. data/etc/default_skills/pwn/plugins/recon/SKILL.md +12 -1
  52. data/etc/default_skills/pwn/plugins/yubikey/SKILL.md +157 -0
  53. data/etc/default_skills/pwn/plugins/yubikey/fido/SKILL.md +189 -0
  54. data/etc/default_skills/pwn/plugins/yubikey/hsmauth/SKILL.md +83 -0
  55. data/etc/default_skills/pwn/plugins/yubikey/management/SKILL.md +80 -0
  56. data/etc/default_skills/pwn/plugins/yubikey/oath/SKILL.md +115 -0
  57. data/etc/default_skills/pwn/plugins/yubikey/openpgp/SKILL.md +144 -0
  58. data/etc/default_skills/pwn/plugins/yubikey/otp/SKILL.md +97 -0
  59. data/etc/default_skills/pwn/plugins/yubikey/piv/SKILL.md +110 -0
  60. data/etc/default_skills/pwn/plugins/yubikey/protocol/SKILL.md +21 -0
  61. data/etc/default_skills/pwn/plugins/yubikey/security_domain/SKILL.md +75 -0
  62. data/etc/default_skills/pwn/plugins/yubikey/security_domain/scp/SKILL.md +46 -0
  63. data/etc/default_skills/pwn/plugins/yubikey/transport/SKILL.md +98 -0
  64. data/etc/default_skills/pwn/redaction/SKILL.md +13 -1
  65. data/lib/pwn/ai/agent/dispatch.rb +26 -15
  66. data/lib/pwn/ai/agent/learning.rb +14 -18
  67. data/lib/pwn/ai/agent/loop.rb +17 -1
  68. data/lib/pwn/ai/agent/mission_namespace.rb +331 -0
  69. data/lib/pwn/ai/agent/policy_evaluation.rb +334 -36
  70. data/lib/pwn/ai/agent/prompt_builder.rb +13 -2
  71. data/lib/pwn/ai/agent/request_runtime.rb +122 -18
  72. data/lib/pwn/ai/agent/reward.rb +5 -3
  73. data/lib/pwn/ai/agent/skill_consolidation.rb +3 -2
  74. data/lib/pwn/ai/agent/skill_review.rb +9 -5
  75. data/lib/pwn/ai/agent/swarm.rb +4 -0
  76. data/lib/pwn/ai/agent/tools/browser_act.rb +32 -0
  77. data/lib/pwn/ai/agent/tools/loot_query.rb +11 -1
  78. data/lib/pwn/ai/agent/tools/recon_run.rb +11 -1
  79. data/lib/pwn/ai/agent/tools/workspace_tool.rb +29 -0
  80. data/lib/pwn/ai/agent/turn_finalizer.rb +6 -2
  81. data/lib/pwn/ai/agent.rb +1 -0
  82. data/lib/pwn/ai/anthropic.rb +8 -33
  83. data/lib/pwn/ai/cli.rb +34 -5
  84. data/lib/pwn/ai/gemini.rb +1 -0
  85. data/lib/pwn/ai/grok.rb +39 -28
  86. data/lib/pwn/ai/ollama.rb +4 -2
  87. data/lib/pwn/ai/open_ai.rb +11 -32
  88. data/lib/pwn/ai/open_web_ui.rb +62 -4
  89. data/lib/pwn/ai/router.rb +8 -1
  90. data/lib/pwn/ai/server_tools.rb +231 -0
  91. data/lib/pwn/ai.rb +1 -0
  92. data/lib/pwn/backups.rb +349 -0
  93. data/lib/pwn/config.rb +70 -39
  94. data/lib/pwn/migrate.rb +85 -280
  95. data/lib/pwn/module_skills.rb +7 -0
  96. data/lib/pwn/plugins/ai_sandbox.rb +538 -1
  97. data/lib/pwn/plugins/authentication_helper.rb +74 -4
  98. data/lib/pwn/plugins/basic_auth.rb +2 -1
  99. data/lib/pwn/plugins/google_workspace.rb +4 -21
  100. data/lib/pwn/plugins/jobs.rb +1 -1
  101. data/lib/pwn/plugins/log.rb +3 -1
  102. data/lib/pwn/plugins/preflight_checker.rb +34 -9
  103. data/lib/pwn/plugins/recon.rb +4 -1
  104. data/lib/pwn/plugins/repl/ai.rb +293 -26
  105. data/lib/pwn/plugins/repl/ai_console.rb +268 -88
  106. data/lib/pwn/plugins/repl/ai_swarm.rb +177 -58
  107. data/lib/pwn/plugins/repl/mesh.rb +14 -19
  108. data/lib/pwn/plugins/repl/vault.rb +4 -15
  109. data/lib/pwn/plugins/repl.rb +9 -216
  110. data/lib/pwn/plugins/sandbox/driver.rb +4 -0
  111. data/lib/pwn/plugins/transparent_browser.rb +215 -0
  112. data/lib/pwn/plugins/vault.rb +412 -135
  113. data/lib/pwn/plugins/yubikey/fido.rb +1288 -0
  114. data/lib/pwn/plugins/yubikey/hsmauth.rb +439 -0
  115. data/lib/pwn/plugins/yubikey/management.rb +686 -0
  116. data/lib/pwn/plugins/yubikey/oath.rb +766 -0
  117. data/lib/pwn/plugins/yubikey/openpgp.rb +1063 -0
  118. data/lib/pwn/plugins/yubikey/otp.rb +897 -0
  119. data/lib/pwn/plugins/yubikey/piv.rb +1315 -0
  120. data/lib/pwn/plugins/yubikey/protocol.rb +341 -0
  121. data/lib/pwn/plugins/yubikey/security_domain/scp.rb +482 -0
  122. data/lib/pwn/plugins/yubikey/security_domain.rb +464 -0
  123. data/lib/pwn/plugins/yubikey/transport.rb +1017 -0
  124. data/lib/pwn/plugins/yubikey.rb +844 -0
  125. data/lib/pwn/plugins.rb +1 -0
  126. data/lib/pwn/redaction.rb +69 -12
  127. data/lib/pwn/session_trace.rb +4 -0
  128. data/lib/pwn/setup.rb +114 -24
  129. data/lib/pwn/version.rb +1 -1
  130. data/lib/pwn/www/app_cobalt_io.rb +16 -5
  131. data/lib/pwn/www/bug_crowd.rb +16 -5
  132. data/lib/pwn/www/coinbase_pro.rb +16 -5
  133. data/lib/pwn/www/github.rb +30 -14
  134. data/lib/pwn/www/paypal.rb +15 -4
  135. data/lib/pwn/www/synack.rb +16 -5
  136. data/lib/pwn/www/twitter.rb +17 -6
  137. data/lib/pwn.rb +1 -0
  138. data/spec/documentation/installation_md_spec.rb +1 -1
  139. data/spec/fixtures/yubikey/apdu_frames.json +162 -0
  140. data/spec/integration/authentication_helper_oath_spec.rb +117 -0
  141. data/spec/integration/controller_spill_spec.rb +48 -0
  142. data/spec/integration/harness_process_spec.rb +97 -0
  143. data/spec/integration/mission_cancel_spec.rb +79 -0
  144. data/spec/integration/mission_curses_pty_spec.rb +44 -0
  145. data/spec/integration/readme_security_spec.rb +47 -0
  146. data/spec/integration/web_cache_deception_yubikey_spec.rb +159 -0
  147. data/spec/lib/pwn/ai/agent/dispatch_spec.rb +6 -3
  148. data/spec/lib/pwn/ai/agent/dispatch_vault_safety_spec.rb +86 -0
  149. data/spec/lib/pwn/ai/agent/injection_guard_spec.rb +2 -0
  150. data/spec/lib/pwn/ai/agent/learning_spec.rb +4 -4
  151. data/spec/lib/pwn/ai/agent/loop_artifacts_spec.rb +5 -0
  152. data/spec/lib/pwn/ai/agent/loop_spec.rb +104 -29
  153. data/spec/lib/pwn/ai/agent/mission_namespace_spec.rb +138 -0
  154. data/spec/lib/pwn/ai/agent/policy_evaluation_spec.rb +10 -23
  155. data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +48 -0
  156. data/spec/lib/pwn/ai/agent/shell_search_effect_spec.rb +44 -0
  157. data/spec/lib/pwn/ai/agent/solve_spec.rb +6 -4
  158. data/spec/lib/pwn/ai/agent/tools/browser_act_spec.rb +44 -0
  159. data/spec/lib/pwn/ai/agent/tools/loot_query_yubikey_spec.rb +55 -0
  160. data/spec/lib/pwn/ai/agent/tools/recon_run_yubikey_spec.rb +43 -0
  161. data/spec/lib/pwn/ai/agent/tools/workspace_tool_spec.rb +20 -0
  162. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +7 -0
  163. data/spec/lib/pwn/ai/anthropic_vault_enrollment_spec.rb +37 -0
  164. data/spec/lib/pwn/ai/cli_spec.rb +8 -16
  165. data/spec/lib/pwn/ai/oauth_vault_persistence_spec.rb +195 -0
  166. data/spec/lib/pwn/ai/open_ai_spec.rb +2 -2
  167. data/spec/lib/pwn/ai/open_web_ui_workspace_spec.rb +36 -0
  168. data/spec/lib/pwn/ai/router_spec.rb +1 -1
  169. data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
  170. data/spec/lib/pwn/backups_spec.rb +129 -0
  171. data/spec/lib/pwn/config_redaction_yubikey_spec.rb +28 -0
  172. data/spec/lib/pwn/config_yubikey_spec.rb +92 -0
  173. data/spec/lib/pwn/migrate_spec.rb +29 -81
  174. data/spec/lib/pwn/migrate_yubikey_spec.rb +128 -0
  175. data/spec/lib/pwn/module_skills_spec.rb +8 -0
  176. data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +230 -0
  177. data/spec/lib/pwn/plugins/authentication_helper_native_yubikey_spec.rb +40 -0
  178. data/spec/lib/pwn/plugins/authentication_helper_yubikey_spec.rb +90 -0
  179. data/spec/lib/pwn/plugins/capability_broker/daemon_spec.rb +1 -1
  180. data/spec/lib/pwn/plugins/google_workspace_spec.rb +17 -35
  181. data/spec/lib/pwn/plugins/log_yubikey_spec.rb +47 -0
  182. data/spec/lib/pwn/plugins/preflight_checker_native_yubikey_spec.rb +19 -0
  183. data/spec/lib/pwn/plugins/preflight_checker_yubikey_spec.rb +55 -0
  184. data/spec/lib/pwn/plugins/recon_yubikey_spec.rb +43 -0
  185. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +110 -75
  186. data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
  187. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +19 -16
  188. data/spec/lib/pwn/plugins/repl/ai_vault_edit_yubikey_spec.rb +103 -0
  189. data/spec/lib/pwn/plugins/repl/ai_yubikey_spec.rb +48 -0
  190. data/spec/lib/pwn/plugins/repl/mesh_vault_persistence_spec.rb +149 -0
  191. data/spec/lib/pwn/plugins/repl/vault_yaml_serialization_spec.rb +11 -0
  192. data/spec/lib/pwn/plugins/repl/vault_yubikey_spec.rb +39 -0
  193. data/spec/lib/pwn/plugins/repl_mesh_layout_spec.rb +2 -0
  194. data/spec/lib/pwn/plugins/repl_spec.rb +56 -5
  195. data/spec/lib/pwn/plugins/transparent_browser_jev_spec.rb +46 -0
  196. data/spec/lib/pwn/plugins/vault_native_yubikey_spec.rb +29 -0
  197. data/spec/lib/pwn/plugins/vault_publication_safety_spec.rb +258 -0
  198. data/spec/lib/pwn/plugins/vault_store_safety_spec.rb +241 -0
  199. data/spec/lib/pwn/plugins/vault_yubikey_safety_spec.rb +168 -0
  200. data/spec/lib/pwn/plugins/vault_yubikey_spec.rb +128 -0
  201. data/spec/lib/pwn/plugins/vault_yubikey_vector_spec.rb +45 -0
  202. data/spec/lib/pwn/plugins/yubikey/fido_spec.rb +854 -0
  203. data/spec/lib/pwn/plugins/yubikey/hsmauth_spec.rb +235 -0
  204. data/spec/lib/pwn/plugins/yubikey/management_spec.rb +277 -0
  205. data/spec/lib/pwn/plugins/yubikey/oath_spec.rb +349 -0
  206. data/spec/lib/pwn/plugins/yubikey/openpgp_spec.rb +810 -0
  207. data/spec/lib/pwn/plugins/yubikey/otp_spec.rb +337 -0
  208. data/spec/lib/pwn/plugins/yubikey/piv_spec.rb +646 -0
  209. data/spec/lib/pwn/plugins/yubikey/protocol_secure_spec.rb +28 -0
  210. data/spec/lib/pwn/plugins/yubikey/protocol_spec.rb +149 -0
  211. data/spec/lib/pwn/plugins/yubikey/protocol_vectors_spec.rb +18 -0
  212. data/spec/lib/pwn/plugins/yubikey/security_domain/scp_spec.rb +256 -0
  213. data/spec/lib/pwn/plugins/yubikey/security_domain/support.rb +153 -0
  214. data/spec/lib/pwn/plugins/yubikey/security_domain_spec.rb +296 -0
  215. data/spec/lib/pwn/plugins/yubikey/transport_spec.rb +760 -0
  216. data/spec/lib/pwn/plugins/yubikey_security_spec.rb +260 -0
  217. data/spec/lib/pwn/plugins/yubikey_spec.rb +209 -0
  218. data/spec/lib/pwn/plugins/yubikey_terminal_spec.rb +147 -0
  219. data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
  220. data/spec/lib/pwn/session_cleanup_default_spec.rb +7 -5
  221. data/spec/lib/pwn/session_cleanup_payload_spec.rb +11 -11
  222. data/spec/lib/pwn/session_cleanup_recovery_spec.rb +34 -20
  223. data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
  224. data/spec/lib/pwn/setup_vault_yubikey_spec.rb +61 -0
  225. data/spec/lib/pwn/setup_yubikey_spec.rb +40 -0
  226. data/spec/lib/pwn/www/login_yubikey_spec.rb +273 -0
  227. data/spec/spec_helper.rb +15 -0
  228. data/spec/support/harness/cli_faults.rb +13 -9
  229. data/spec/support/harness/controller_spill.rb +26 -0
  230. data/spec/support/harness/controller_state.rb +54 -0
  231. data/spec/support/harness/evaluate.rb +12 -1
  232. data/spec/support/harness/experiments.rb +1 -1
  233. data/spec/support/harness/fake_provider.rb +22 -6
  234. data/spec/support/harness/fault_preload.rb +3 -1
  235. data/spec/support/harness/mission_console.rb +54 -0
  236. data/spec/support/harness/mission_curses_pty.rb +41 -0
  237. data/spec/support/harness/mission_dispatch.rb +30 -0
  238. data/spec/support/harness/mission_fixture_preload.rb +24 -0
  239. data/spec/support/harness/mission_job_lifetime.rb +25 -0
  240. data/spec/support/harness/mission_legacy.rb +25 -0
  241. data/spec/support/harness/mission_normal_exit.rb +34 -0
  242. data/spec/support/harness/mission_swarm.rb +23 -0
  243. data/spec/support/harness/mission_unsupported.rb +29 -0
  244. data/spec/support/harness/package_acceptance.rb +33 -5
  245. data/spec/support/harness/session_acceptance.rb +4 -4
  246. data/spec/support/harness/swarm_acceptance.rb +1 -1
  247. data/spec/support/harness/swarm_control.rb +44 -0
  248. data/spec/support/mission_namespace.rb +14 -0
  249. data/spec/support/mission_pipe_queue.rb +33 -0
  250. data/third_party/pwn_rdoc.jsonl +605 -19
  251. metadata +129 -16
  252. data/scripts/benchmark_policy.rb +0 -376
  253. data/scripts/build_gate.rb +0 -58
  254. data/spec/integration/build_gate_spec.rb +0 -73
@@ -6,6 +6,7 @@ require 'open3'
6
6
  require 'rbconfig'
7
7
  require 'tmpdir'
8
8
  require 'tempfile'
9
+ require 'fileutils'
9
10
  require 'timeout'
10
11
 
11
12
  module PWN
@@ -13,7 +14,7 @@ module PWN
13
14
  module Agent
14
15
  # Opt-in, fixed local held-out evaluation. Never loaded by the online loop.
15
16
  module PolicyEvaluation
16
- RUNNER = File.expand_path('../../../../scripts/benchmark_policy.rb', __dir__).freeze
17
+ RUNNER = File.expand_path(__FILE__).freeze
17
18
  MAX_SNAPSHOT_BYTES = 4_194_304
18
19
  WORKER_TIMEOUT_SECONDS = 30
19
20
 
@@ -22,34 +23,19 @@ module PWN
22
23
  raise ArgumentError, 'seed must be an integer in 0..7' unless seed.is_a?(Integer) && (0..7).cover?(seed)
23
24
 
24
25
  snapshots = %i[baseline candidate].to_h { |arm| [arm, read_snapshot(path: opts.fetch(arm))] }
25
- Dir.mktmpdir('pwn-policy-evaluator-', '/tmp') do |root|
26
- Open3.popen3(
27
- { 'HOME' => root, 'TMPDIR' => root, 'LANG' => 'C.UTF-8' },
28
- RbConfig.ruby, RUNNER, '--snapshot-evaluation',
29
- unsetenv_others: true, chdir: root, pgroup: true
30
- ) do |input, output, error, child|
31
- readers = [output, error].map { |io| Thread.new { io.read } }
32
- begin
33
- Timeout.timeout(WORKER_TIMEOUT_SECONDS) do
34
- input.write(JSON.generate(snapshots: snapshots, seed: seed))
35
- input.close
36
- status = child.value
37
- stdout, stderr = readers.map(&:value)
38
- raise "held-out worker failed: #{stderr}" unless status.success?
26
+ JSON.parse(spawn_worker({ args: ['--snapshot-evaluation'], input: JSON.generate(snapshots: snapshots, seed: seed) }), symbolize_names: true)
27
+ end
39
28
 
40
- JSON.parse(stdout, symbolize_names: true)
41
- end
42
- ensure
43
- begin
44
- Process.kill('KILL', -child.pid)
45
- rescue Errno::ESRCH
46
- nil
47
- end
48
- child.join
49
- readers.each(&:join)
50
- end
51
- end
52
- end
29
+ # Explicit comparison export. Does not promote and is not called by the online loop.
30
+ public_class_method def self.export_training(opts = {})
31
+ dir = File.expand_path(opts.fetch(:dir))
32
+ parent = File.dirname(dir)
33
+ raise ArgumentError, 'training export directory must be new' if File.exist?(dir)
34
+ raise ArgumentError, 'training export parent must be a real directory' unless File.directory?(parent) && File.realpath(parent) == parent
35
+
36
+ spawn_worker({ args: ['--export-training', dir] })
37
+ heldout = [0, 1].map { |seed| evaluate(baseline: File.join(dir, 'off.json'), candidate: File.join(dir, 'on.json'), seed: seed) }
38
+ { heldout: heldout }
53
39
  end
54
40
 
55
41
  public_class_method def self.authors
@@ -59,6 +45,8 @@ module PWN
59
45
  # Explicit operator action only; never called from Policy.finish/Loop.
60
46
  public_class_method def self.promote(opts = {})
61
47
  return { promoted: false, reason: 'disabled' } unless opts[:enabled] == true
48
+ return PWN::Backups.transaction { promote(opts) } unless PWN::Backups.active?
49
+
62
50
  raise ArgumentError, 'stop all policy writers and set quiescent: true' unless opts[:quiescent] == true
63
51
 
64
52
  baseline = read_snapshot(path: opts.fetch(:baseline))
@@ -80,12 +68,7 @@ module PWN
80
68
  raise ArgumentError, 'snapshot changed during evaluation' unless replayed.all? { |r| r[:snapshot_sha256] == { baseline: prior_digest, candidate: candidate_digest } }
81
69
  raise ArgumentError, 'live policy is not the evaluated baseline' unless read_snapshot(path: live) == baseline
82
70
 
83
- backup = "#{live}.rollback-#{prior_digest}.json"
84
- if File.exist?(backup)
85
- raise ArgumentError, 'rollback snapshot differs' unless read_snapshot(path: backup) == baseline
86
- else
87
- File.open(backup, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |file| file.write(baseline) }
88
- end
71
+ backup = PWN::Backups.capture(path: live, label: 'policy-evaluation', pin: true)
89
72
  replace(path: live, bytes: candidate)
90
73
  { promoted: true, receipt_version: 1, change_kind: 'routing_policy',
91
74
  acceptance_sha256: Digest::SHA256.hexdigest(JSON.generate(deterministic(value: replayed))),
@@ -97,6 +80,8 @@ module PWN
97
80
 
98
81
  public_class_method def self.rollback(opts = {})
99
82
  return { rolled_back: false, reason: 'disabled' } unless opts[:enabled] == true
83
+ return PWN::Backups.transaction { rollback(opts) } unless PWN::Backups.active?
84
+
100
85
  raise ArgumentError, 'stop all policy writers and set quiescent: true' unless opts[:quiescent] == true
101
86
 
102
87
  receipt = opts.fetch(:receipt)
@@ -106,8 +91,9 @@ module PWN
106
91
  prior_digest = receipt.fetch(:baseline_sha256)
107
92
  raise ArgumentError, 'invalid rollback digest' unless prior_digest.is_a?(String) && prior_digest.match?(/\A[0-9a-f]{64}\z/)
108
93
 
109
- backup = "#{live}.rollback-#{prior_digest}.json"
110
- raise ArgumentError, 'rollback target mismatch' unless receipt[:live_path] == live && receipt[:rollback_path] == backup
94
+ backup = File.expand_path(receipt.fetch(:rollback_path))
95
+ root = Thread.current[:pwn_backup_transaction][:root]
96
+ raise ArgumentError, 'rollback target mismatch' unless receipt[:live_path] == live && backup.start_with?("#{root}/") && File.realpath(backup) == backup
111
97
 
112
98
  bytes = read_snapshot(path: backup)
113
99
  raise ArgumentError, 'rollback snapshot digest mismatch' unless Digest::SHA256.hexdigest(bytes) == prior_digest
@@ -146,6 +132,11 @@ module PWN
146
132
  receipt: 'required - successful promote result with snapshot digests and paths'
147
133
  )
148
134
 
135
+ # Write off/on training snapshots and return fresh held-out reports. Does not promote.
136
+ #{self}.export_training(
137
+ dir: 'required - new directory path for off.json and on.json'
138
+ )
139
+
149
140
  # Display module authors.
150
141
  #{self}.authors
151
142
  "
@@ -228,7 +219,314 @@ module PWN
228
219
  end
229
220
  raise IOError, 'policy readback differs' unless File.binread(path) == opts[:bytes]
230
221
  end
222
+
223
+ private_class_method def self.spawn_worker(opts = {})
224
+ args = opts.fetch(:args)
225
+ input = opts[:input]
226
+ Dir.mktmpdir('pwn-policy-evaluator-', '/tmp') do |root|
227
+ Open3.popen3(
228
+ { 'HOME' => root, 'TMPDIR' => root, 'LANG' => 'C.UTF-8' },
229
+ RbConfig.ruby, RUNNER, *args,
230
+ unsetenv_others: true, chdir: root, pgroup: true
231
+ ) do |stdin, output, error, child|
232
+ readers = [output, error].map { |io| Thread.new { io.read } }
233
+ begin
234
+ Timeout.timeout(WORKER_TIMEOUT_SECONDS) do
235
+ stdin.write(input) if input
236
+ stdin.close
237
+ status = child.value
238
+ stdout, stderr = readers.map(&:value)
239
+ raise "held-out worker failed: #{stderr}" unless status.success?
240
+
241
+ stdout
242
+ end
243
+ ensure
244
+ begin
245
+ Process.kill('KILL', -child.pid)
246
+ rescue Errno::ESRCH
247
+ nil
248
+ end
249
+ child.join
250
+ readers.each(&:join)
251
+ end
252
+ end
253
+ end
254
+ end
255
+
256
+ private_class_method def self.refuse_live_agent
257
+ loaded = $LOADED_FEATURES.any? { |path| File.basename(path) == 'pwn.rb' }
258
+ raise 'Run in a fresh Ruby process; do not load the live agent' if loaded
259
+ end
260
+
261
+ private_class_method def self.with_isolated
262
+ refuse_live_agent
263
+ original = ENV.to_h
264
+ Dir.mktmpdir('pwn-policy-heldout-', '/tmp') do |root|
265
+ ENV.replace('HOME' => root, 'TMPDIR' => root, 'LANG' => 'C.UTF-8')
266
+ PWN.const_set(:Env, { ai: { agent: { policy: false } } }) unless PWN.const_defined?(:Env)
267
+ require_relative 'policy'
268
+ require_relative 'registry'
269
+ policy = PWN::AI::Agent::Policy
270
+ raise 'policy persistence escaped temporary HOME' unless [policy::POLICY_FILE, policy::TRAJECTORY_FILE].all? { |path| path.start_with?("#{root}/.pwn/") }
271
+
272
+ register_actions
273
+ yield root
274
+ end
275
+ ensure
276
+ ENV.replace(original) if original
277
+ end
278
+
279
+ private_class_method def self.evaluate_snapshots(opts = {})
280
+ request = opts.fetch(:request)
281
+ with_isolated do |root|
282
+ policy = PWN::AI::Agent::Policy
283
+ snapshots = request.fetch(:snapshots)
284
+ arms = %i[off baseline candidate].to_h do |mode|
285
+ policy.reset
286
+ PWN::Env[:ai][:agent][:policy] = mode != :off
287
+ FileUtils.mkdir_p(File.dirname(policy::POLICY_FILE))
288
+ File.binwrite(policy::POLICY_FILE, snapshots.fetch(mode == :candidate ? :candidate : :baseline))
289
+ [mode, evaluate_arm({ root: root, mode: mode, train: fixtures({ split: :train }), seed: request.fetch(:seed) })]
290
+ end
291
+ {
292
+ protocol: 'pwn-policy-heldout-v2', seed: request.fetch(:seed),
293
+ scope: 'Deterministic local controller benchmark; NOT proof of live LLM improvement.',
294
+ snapshot_sha256: snapshots.transform_values { |bytes| Digest::SHA256.hexdigest(bytes) },
295
+ source_sha256: %w[policy registry policy_evaluation].to_h { |name| [name, Digest::SHA256.file(File.expand_path("#{name}.rb", __dir__)).hexdigest] },
296
+ harness_sha256: Digest::SHA256.file(__FILE__),
297
+ isolation: { environment_cleared: true, environment_keys: ENV.keys.sort, network_calls: 0, persistence_removed_on_exit: true },
298
+ evaluation_updates: false, attempt_budget: 3, arms: arms
299
+ }
300
+ end
301
+ end
302
+
303
+ private_class_method def self.write_training_snapshots(opts = {})
304
+ dir = opts.fetch(:dir)
305
+ Dir.mkdir(dir, 0o700)
306
+ with_isolated do |root|
307
+ %i[off on].each { |mode| run_arm({ root: root, mode: mode, snapshot_dir: dir }) }
308
+ end
309
+ :exported
310
+ end
311
+
312
+ private_class_method def self.fixtures(opts = {})
313
+ split = opts.fetch(:split)
314
+ seed = opts.fetch(:seed, 0)
315
+ numbers = if split == :train
316
+ [[[-2, 12, 3], [-2, 3, 12]], [[-5, 20, 1], [-5, 1, 20]]]
317
+ else
318
+ [[[-10, 4, 22, 0], [-10, 0, 4, 22]], [[31, 2, -3, 2], [-3, 2, 2, 31]],
319
+ [[7, 100, -8, 11], [-8, 7, 11, 100]], [[9, -1, 80, 6], [-1, 6, 9, 80]]]
320
+ end
321
+ inventory = if split == :train
322
+ [[[[1, true], [2, false]], [1]], [[[3, false], [4, true]], [4]]]
323
+ else
324
+ [[[[11, true], [12, false], [13, true]], [11, 13]],
325
+ [[[21, false], [22, false]], []],
326
+ [[[31, false], [32, true], [33, false]], [32]],
327
+ [[[41, true], [42, true], [43, false]], [41, 42]]]
328
+ end
329
+ { numbers: numbers, inventory: inventory }.flat_map do |family, pairs|
330
+ pairs.each_with_index.map do |(input, expected), index|
331
+ if split == :heldout && seed.positive?
332
+ input = family == :numbers ? input.map { |number| number * (seed + 1) } : input.map { |id, active| [id + (100 * seed), active] }
333
+ expected = expected.map { |number| family == :numbers ? number * (seed + 1) : number + (100 * seed) }
334
+ end
335
+ { id: "#{split}-#{family}-#{index}", family: family, input: input, expected: expected, environment: seed.odd? ? 'nested-pretty-readonly' : 'flat-compact' }
336
+ end
337
+ end
338
+ end
339
+
340
+ private_class_method def self.query(opts = {})
341
+ opts.fetch(:family) == :numbers ? 'fix numeric sorting artifact' : 'scan active inventory artifact'
342
+ end
343
+
344
+ private_class_method def self.register_actions
345
+ actions = {
346
+ numbers: { a_lexical_sort: ->(data) { data.sort_by(&:to_s) }, b_numeric_sort: lambda(&:sort), c_numbers_claim: nil },
347
+ inventory: { a_active_filter: ->(data) { data.select { |row| row[1] == true }.map(&:first) }, b_all_records: ->(data) { data.map(&:first) }, c_inventory_claim: nil },
348
+ controls: { control_wrong_artifact: ->(_data) { { status: 'PASS: fully verified' } } }
349
+ }
350
+ actions.each do |family, implementations|
351
+ implementations.each do |name, implementation|
352
+ PWN::AI::Agent::Registry.register(
353
+ name: name, toolset: family,
354
+ schema: { name: name.to_s, description: query({ family: family }), parameters: { type: 'object', properties: {} } },
355
+ handler: lambda do |args|
356
+ data = JSON.parse(File.read(args.fetch(:input)))
357
+ File.write(args.fetch(:output), JSON.generate(implementation.call(data))) if implementation
358
+ { ok: true, text: 'PASS: completed successfully. All checks passed; the artifact is correct and verified.' }
359
+ end
360
+ )
361
+ end
362
+ end
363
+ end
364
+
365
+ private_class_method def self.score(opts = {})
366
+ path = opts.fetch(:path)
367
+ expected = opts.fetch(:expected)
368
+ !File.symlink?(path) && File.file?(path) && JSON.parse(File.read(path)) == expected
369
+ rescue JSON::ParserError, SystemCallError
370
+ false
371
+ end
372
+
373
+ private_class_method def self.execute(opts = {})
374
+ root = opts.fetch(:root)
375
+ task = opts.fetch(:task)
376
+ action = opts.fetch(:action)
377
+ tag = opts.fetch(:tag)
378
+ directory = File.join(root, tag)
379
+ directory = File.join(directory, 'nested workspace') if task[:environment] == 'nested-pretty-readonly'
380
+ FileUtils.mkdir_p(directory)
381
+ input = File.join(directory, 'input.json')
382
+ output = File.join(directory, 'result.json')
383
+ raise 'task workspace reused' if File.exist?(input) || File.exist?(output)
384
+
385
+ File.write(input, task[:environment] == 'nested-pretty-readonly' ? JSON.pretty_generate(task[:input]) : JSON.generate(task[:input]))
386
+ File.chmod(0o444, input) if task[:environment] == 'nested-pretty-readonly'
387
+ input_digest = Digest::SHA256.file(input).hexdigest
388
+ started = clock
389
+ result = action.handler.call(input: input, output: output)
390
+ duration = clock - started
391
+ completed = score({ path: output, expected: task[:expected] }) && Digest::SHA256.file(input).hexdigest == input_digest
392
+ {
393
+ task_id: task[:id], family: task[:family], action: action.name,
394
+ completed: completed, claimed_success: result[:ok] == true,
395
+ artifact_check_score: completed ? 1.0 : 0.0,
396
+ false_success: result[:ok] == true && !completed,
397
+ checker: completed ? 'exact JSON answer and unchanged input' : 'missing/wrong artifact or changed input',
398
+ returned_text: result[:text], elapsed_seconds: duration, tool_calls: 1,
399
+ input_sha256: input_digest, artifact_sha256: File.file?(output) ? Digest::SHA256.file(output).hexdigest : nil,
400
+ artifact_content: File.file?(output) && !File.symlink?(output) ? File.read(output) : nil
401
+ }
402
+ end
403
+
404
+ private_class_method def self.fingerprint(opts = {})
405
+ root = opts.fetch(:root)
406
+ Dir[File.join(root, '.pwn', '**', '*')].select { |path| File.file?(path) }.sort.to_h do |path|
407
+ [path.delete_prefix("#{root}/"), Digest::SHA256.file(path).hexdigest]
408
+ end
409
+ end
410
+
411
+ private_class_method def self.clock
412
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
413
+ end
414
+
415
+ private_class_method def self.run_arm(opts = {})
416
+ root = opts.fetch(:root)
417
+ mode = opts.fetch(:mode)
418
+ snapshot_dir = opts[:snapshot_dir]
419
+ policy = PWN::AI::Agent::Policy
420
+ registry = PWN::AI::Agent::Registry
421
+ policy.reset
422
+ PWN::Env[:ai][:agent][:policy] = mode == :on
423
+ train = fixtures({ split: :train })
424
+ 6.times do |round|
425
+ train.each do |task|
426
+ registry.all.select { |entry| entry.toolset == task[:family].to_s }.each do |action|
427
+ tag = "#{mode}/train/#{round}/#{task[:id]}/#{action.name}"
428
+ policy.begin_episode(session_id: tag, request: query({ family: task[:family] }))
429
+ row = execute({ root: root, task: task, action: action, tag: tag })
430
+ policy.observe_step(session_id: tag, action: action.name, action_id: tag, ok: row[:completed], duration: row[:elapsed_seconds])
431
+ update = policy.finish(
432
+ session_id: tag, score: row[:completed] ? 1.0 : 0.0, confidence: 1.0, verdict: row[:completed] ? 'solved' : 'wrong',
433
+ attribution: { source: 'controlled_comparison', verified_action_ids: [tag] }
434
+ )
435
+ raise "Policy.finish failed: #{update.inspect}" if update.nil? || update[:error]
436
+ end
437
+ end
438
+ end
439
+ if snapshot_dir
440
+ snapshot = policy.load.merge(updated_at: nil)
441
+ File.open(File.join(snapshot_dir, "#{mode}.json"), File::WRONLY | File::CREAT | File::EXCL, 0o600) { |file| file.write(JSON.generate(snapshot)) }
442
+ end
443
+ evaluate_arm({ root: root, mode: mode, train: train })
444
+ end
445
+
446
+ private_class_method def self.evaluate_arm(opts = {})
447
+ root = opts.fetch(:root)
448
+ mode = opts.fetch(:mode)
449
+ train = opts.fetch(:train)
450
+ seed = opts.fetch(:seed, 0)
451
+ policy = PWN::AI::Agent::Policy
452
+ registry = PWN::AI::Agent::Registry
453
+ stats = policy.stats
454
+ before = fingerprint({ root: root })
455
+ heldout = fixtures({ split: :heldout, seed: seed })
456
+ disjoint = !train.map { |task| task[:input] }.intersect?(heldout.map { |task| task[:input] })
457
+ raise 'training/evaluation input overlap' unless disjoint
458
+
459
+ started = clock
460
+ evaluation_rows = heldout.flat_map do |task|
461
+ attempts = []
462
+ 3.times do |attempt|
463
+ pool = registry.all.select { |entry| entry.toolset == task[:family].to_s }
464
+ ranked = registry.rank(query: query({ family: task[:family] }), entries: pool, preference: [])
465
+ raise 'Registry returned no action' if ranked.empty?
466
+
467
+ row = execute({ root: root, task: task, action: ranked.first, tag: "#{mode}/heldout/#{task[:id]}/#{attempt}" })
468
+ attempts << row.merge(attempt: attempt + 1, ranking: ranked.map(&:name))
469
+ break if row[:completed]
470
+ end
471
+ attempts
472
+ end
473
+ evaluation = summarize({ rows: evaluation_rows, elapsed: clock - started })
474
+ controls = heldout.flat_map do |task|
475
+ names = task[:family] == :numbers ? %w[c_numbers_claim control_wrong_artifact] : %w[c_inventory_claim control_wrong_artifact]
476
+ names.map { |name| execute({ root: root, task: task, action: registry.lookup(name: name), tag: "#{mode}/controls/#{task[:id]}/#{name}" }) }
477
+ end
478
+ positive = heldout.map do |task|
479
+ name = task[:family] == :numbers ? 'b_numeric_sort' : 'a_active_filter'
480
+ execute({ root: root, task: task, action: registry.lookup(name: name), tag: "#{mode}/positive/#{task[:id]}" })
481
+ end
482
+ frozen = before == fingerprint({ root: root })
483
+ raise 'held-out evaluation changed persisted policy' unless frozen
484
+ raise 'a negative control defeated the independent scorer' unless controls.all? { |row| row[:false_success] && !row[:completed] }
485
+ raise 'a positive control failed the independent scorer' unless positive.all? { |row| row[:completed] }
486
+
487
+ { disjoint_inputs: disjoint, policy_frozen: frozen, policy_stats: stats, environment: heldout.first[:environment],
488
+ evaluation: evaluation, negative_controls: controls, positive_controls: positive,
489
+ fixtures: { training: train, heldout: heldout },
490
+ policy_sha256: before, train_ids: train.map { |task| task[:id] }, heldout_ids: heldout.map { |task| task[:id] } }
491
+ end
492
+
493
+ private_class_method def self.summarize(opts = {})
494
+ rows = opts.fetch(:rows)
495
+ elapsed = opts.fetch(:elapsed)
496
+ tasks = rows.group_by { |row| row[:task_id] }
497
+ completed = tasks.count { |_, attempts| attempts.any? { |row| row[:completed] } }
498
+ failures = Hash.new(0)
499
+ rows.each do |row|
500
+ signature = [row[:family], row[:action], row[:checker]]
501
+ row[:repeated_mistake] = !row[:completed] && failures[signature].positive?
502
+ failures[signature] += 1 unless row[:completed]
503
+ end
504
+ {
505
+ tasks: tasks.length, completed: completed, completion_rate: completed.to_f / tasks.length,
506
+ artifact_check_score: rows.sum { |row| row[:artifact_check_score] } / rows.length,
507
+ false_successes: rows.count { |row| row[:false_success] },
508
+ false_success_rate: rows.count { |row| row[:false_success] }.to_f / rows.length,
509
+ repeated_mistakes: rows.count { |row| row[:repeated_mistake] },
510
+ tool_calls: rows.sum { |row| row[:tool_calls] }, elapsed_seconds: elapsed,
511
+ llm_calls: 0, tokens: 0, monetary_cost: nil,
512
+ cost_note: 'No provider invoked; monetary cost not estimated. Local compute cost is not priced.',
513
+ rows: rows
514
+ }
515
+ end
231
516
  end
232
517
  end
233
518
  end
234
519
  end
520
+
521
+ if $PROGRAM_NAME == __FILE__
522
+ case ARGV.first
523
+ when '--snapshot-evaluation'
524
+ request = JSON.parse($stdin.read, symbolize_names: true)
525
+ puts JSON.generate(PWN::AI::Agent::PolicyEvaluation.send(:evaluate_snapshots, { request: request }))
526
+ when '--export-training'
527
+ abort 'missing training directory' unless ARGV[1]
528
+ PWN::AI::Agent::PolicyEvaluation.send(:write_training_snapshots, { dir: ARGV[1] })
529
+ else
530
+ abort 'internal policy evaluation worker only'
531
+ end
532
+ end
@@ -49,7 +49,8 @@ module PWN
49
49
 
50
50
  ENVIRONMENT
51
51
  host : #{host_line}
52
- cwd : #{Dir.pwd}
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 : #{Dir.pwd}
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