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
@@ -1,376 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'json'
4
- require 'tmpdir'
5
- require 'fileutils'
6
- require 'digest'
7
- require 'optparse'
8
-
9
- # Standalone experiment, deliberately outside the production module tree.
10
- module PolicyBenchmark
11
- def self.score(path:, expected:)
12
- !File.symlink?(path) && File.file?(path) && JSON.parse(File.read(path)) == expected
13
- rescue JSON::ParserError, SystemCallError
14
- false
15
- end
16
-
17
- def self.self_check
18
- Dir.mktmpdir('pwn-policy-check-', '/tmp') do |root|
19
- path = File.join(root, 'result.json')
20
- File.write(path, JSON.generate([2, 10]))
21
- raise 'checker rejected a correct artifact' unless score(path: path, expected: [2, 10])
22
-
23
- File.write(path, 'PASS: all checks passed; result.json was verified successfully.')
24
- raise 'checker accepted convincing prose' if score(path: path, expected: [2, 10])
25
-
26
- File.write(path, JSON.generate([10, 2]))
27
- raise 'checker accepted wrong artifact' if score(path: path, expected: [2, 10])
28
-
29
- File.unlink(path)
30
- raise 'checker accepted missing artifact' if score(path: path, expected: [2, 10])
31
-
32
- other = File.join(root, 'other.json')
33
- File.write(other, JSON.generate([2, 10]))
34
- File.symlink(other, path)
35
- raise 'checker accepted a symlink instead of a new artifact' if score(path: path, expected: [2, 10])
36
- end
37
- report = Dir.mktmpdir('pwn-policy-snapshot-check-', '/tmp') do |snapshots|
38
- measured = run(snapshot_dir: snapshots)
39
- require_relative '../lib/pwn/ai/agent/policy_evaluation'
40
- evaluator = PWN::AI::Agent::PolicyEvaluation
41
- [0, 1].each do |seed|
42
- heldout = evaluator.evaluate(baseline: File.join(snapshots, 'off.json'), candidate: File.join(snapshots, 'on.json'), seed: seed)
43
- raise 'snapshot protocol missing' unless heldout[:protocol] == 'pwn-policy-heldout-v2'
44
- raise 'snapshot arms missing' unless heldout[:arms].keys == %i[off baseline candidate]
45
-
46
- heldout[:arms].each_value do |arm|
47
- raise 'snapshot evaluation unfrozen or contaminated' unless arm[:policy_frozen] && arm[:disjoint_inputs] && !arm.key?(:training)
48
- raise 'snapshot checks missing' unless arm[:evaluation][:tasks] == 8 && arm[:negative_controls].length == 16 && arm[:positive_controls].length == 8
49
- end
50
- end
51
- puts 'Snapshot self-checks passed'
52
- measured
53
- end
54
- raise 'benchmark did not run both learning arms' unless report[:arms]&.keys == %i[off on]
55
-
56
- report[:arms].each do |mode, arm|
57
- raise 'missing held-out tasks' unless arm[:evaluation][:tasks] == 8
58
- raise 'training contaminated held-out data' unless arm[:disjoint_inputs]
59
- raise 'evaluation mutated the learned policy' unless arm[:policy_frozen]
60
- raise 'negative controls trusted PASS text' unless arm[:negative_controls].length == 16 && arm[:negative_controls].all? { |row| row[:false_success] && !row[:completed] }
61
- raise 'positive controls failed' unless arm[:positive_controls]&.length == 8 && arm[:positive_controls].all? { |row| row[:completed] }
62
- raise 'training has no real executed actions' unless arm[:training][:tool_calls] == 72
63
- raise 'off arm learned' if mode == :off && arm[:policy_stats][:n_updates].positive?
64
- raise 'on arm did not learn' if mode == :on && !arm[:policy_stats][:n_updates].positive?
65
- raise 'provider usage was invented' unless arm[:evaluation][:llm_calls].zero? && arm[:evaluation][:monetary_cost].nil?
66
- end
67
- puts 'Self-checks passed'
68
- end
69
-
70
- def self.run(snapshot_dir: nil)
71
- raise 'Run in a fresh Ruby process; do not load the live agent' if defined?(PWN)
72
-
73
- original_env = ENV.to_h
74
- started = clock
75
- Dir.mktmpdir('pwn-policy-benchmark-', '/tmp') do |root|
76
- # Set HOME BEFORE requiring code with Dir.home-based constants. Do not
77
- # load pwn.rb, user config, tools, network libraries, or provider clients.
78
- ENV.replace('HOME' => root, 'TMPDIR' => root, 'LANG' => 'C.UTF-8')
79
- Object.const_set(:PWN, Module.new)
80
- PWN.const_set(:Env, { ai: { agent: { policy: false } } })
81
- require_relative '../lib/pwn/ai/agent/policy'
82
- require_relative '../lib/pwn/ai/agent/registry'
83
- policy = PWN::AI::Agent::Policy
84
- raise 'policy persistence escaped temporary HOME' unless [policy::POLICY_FILE, policy::TRAJECTORY_FILE].all? { |path| path.start_with?("#{root}/.pwn/") }
85
-
86
- register_actions
87
- arms = %i[off on].to_h { |mode| [mode, run_arm(root: root, mode: mode, snapshot_dir: snapshot_dir)] }
88
- {
89
- benchmark: 'pwn-policy-controller-v1',
90
- scope: 'Deterministic local controller benchmark; NOT proof of live LLM improvement.',
91
- ruby: RUBY_VERSION,
92
- source_sha256: %w[policy registry].to_h { |name| [name, Digest::SHA256.file(File.expand_path("../lib/pwn/ai/agent/#{name}.rb", __dir__)).hexdigest] },
93
- harness_sha256: Digest::SHA256.file(__FILE__).hexdigest,
94
- isolation: { temporary_home: root, environment_cleared: true, network_calls: 0, persistence_removed_on_exit: true },
95
- evaluation_updates: false, attempt_budget: 3, training_rounds: 6,
96
- elapsed_seconds: clock - started, arms: arms
97
- }
98
- end
99
- ensure
100
- ENV.replace(original_env) if original_env
101
- end
102
-
103
- def self.clock
104
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
105
- end
106
-
107
- # Fixed local runner only: no commands, provider settings, or code from callers.
108
- def self.evaluate_snapshots(request:)
109
- raise 'Run in a fresh Ruby process; do not load the live agent' if defined?(PWN)
110
-
111
- original_env = ENV.to_h
112
- Dir.mktmpdir('pwn-policy-heldout-', '/tmp') do |root|
113
- ENV.replace('HOME' => root, 'TMPDIR' => root, 'LANG' => 'C.UTF-8')
114
- Object.const_set(:PWN, Module.new)
115
- PWN.const_set(:Env, { ai: { agent: { policy: false } } })
116
- require_relative '../lib/pwn/ai/agent/policy'
117
- require_relative '../lib/pwn/ai/agent/registry'
118
- policy = PWN::AI::Agent::Policy
119
- raise 'policy persistence escaped temporary HOME' unless [policy::POLICY_FILE, policy::TRAJECTORY_FILE].all? { |path| path.start_with?("#{root}/.pwn/") }
120
-
121
- register_actions
122
- snapshots = request.fetch(:snapshots)
123
- arms = %i[off baseline candidate].to_h do |mode|
124
- policy.reset
125
- PWN::Env[:ai][:agent][:policy] = mode != :off
126
- FileUtils.mkdir_p(File.dirname(policy::POLICY_FILE))
127
- File.binwrite(policy::POLICY_FILE, snapshots.fetch(mode == :candidate ? :candidate : :baseline))
128
- [mode, evaluate_arm(root: root, mode: mode, train: fixtures(split: :train), seed: request.fetch(:seed))]
129
- end
130
- {
131
- protocol: 'pwn-policy-heldout-v2', seed: request.fetch(:seed),
132
- scope: 'Deterministic local controller benchmark; NOT proof of live LLM improvement.',
133
- snapshot_sha256: snapshots.transform_values { |bytes| Digest::SHA256.hexdigest(bytes) },
134
- source_sha256: %w[policy registry policy_evaluation].to_h { |name| [name, Digest::SHA256.file(File.expand_path("../lib/pwn/ai/agent/#{name}.rb", __dir__)).hexdigest] },
135
- harness_sha256: Digest::SHA256.file(__FILE__).hexdigest,
136
- isolation: { environment_cleared: true, environment_keys: ENV.keys.sort, network_calls: 0, persistence_removed_on_exit: true },
137
- evaluation_updates: false, attempt_budget: 3, arms: arms
138
- }
139
- end
140
- ensure
141
- ENV.replace(original_env) if original_env
142
- end
143
-
144
- def self.fixtures(split:, seed: 0)
145
- # Literal answer keys are not generated by, or passed to, action handlers.
146
- numbers = if split == :train
147
- [[[-2, 12, 3], [-2, 3, 12]], [[-5, 20, 1], [-5, 1, 20]]]
148
- else
149
- [[[-10, 4, 22, 0], [-10, 0, 4, 22]], [[31, 2, -3, 2], [-3, 2, 2, 31]],
150
- [[7, 100, -8, 11], [-8, 7, 11, 100]], [[9, -1, 80, 6], [-1, 6, 9, 80]]]
151
- end
152
- inventory = if split == :train
153
- [[[[1, true], [2, false]], [1]], [[[3, false], [4, true]], [4]]]
154
- else
155
- [[[[11, true], [12, false], [13, true]], [11, 13]],
156
- [[[21, false], [22, false]], []],
157
- [[[31, false], [32, true], [33, false]], [32]],
158
- [[[41, true], [42, true], [43, false]], [41, 42]]]
159
- end
160
- { numbers: numbers, inventory: inventory }.flat_map do |family, pairs|
161
- pairs.each_with_index.map do |(input, expected), index|
162
- if split == :heldout && seed.positive?
163
- # Apply a fixed order-preserving transformation to independent literal
164
- # input/answer keys; never derive the answer using an action handler.
165
- input = family == :numbers ? input.map { |n| n * (seed + 1) } : input.map { |id, active| [id + (100 * seed), active] }
166
- expected = expected.map { |n| family == :numbers ? n * (seed + 1) : n + (100 * seed) }
167
- end
168
- { id: "#{split}-#{family}-#{index}", family: family, input: input, expected: expected, environment: seed.odd? ? 'nested-pretty-readonly' : 'flat-compact' }
169
- end
170
- end
171
- end
172
-
173
- def self.query(family:)
174
- family == :numbers ? 'fix numeric sorting artifact' : 'scan active inventory artifact'
175
- end
176
-
177
- def self.register_actions
178
- actions = {
179
- numbers: { a_lexical_sort: ->(data) { data.sort_by(&:to_s) }, b_numeric_sort: lambda(&:sort), c_numbers_claim: nil },
180
- inventory: { a_active_filter: ->(data) { data.select { |row| row[1] == true }.map(&:first) }, b_all_records: ->(data) { data.map(&:first) }, c_inventory_claim: nil },
181
- controls: { control_wrong_artifact: ->(_data) { { status: 'PASS: fully verified' } } }
182
- }
183
- actions.each do |family, implementations|
184
- implementations.each do |name, implementation|
185
- PWN::AI::Agent::Registry.register(
186
- name: name, toolset: family,
187
- schema: { name: name.to_s, description: query(family: family), parameters: { type: 'object', properties: {} } },
188
- handler: lambda do |args|
189
- data = JSON.parse(File.read(args.fetch(:input)))
190
- File.write(args.fetch(:output), JSON.generate(implementation.call(data))) if implementation
191
- { ok: true, text: 'PASS: completed successfully. All checks passed; the artifact is correct and verified.' }
192
- end
193
- )
194
- end
195
- end
196
- end
197
-
198
- def self.execute(root:, task:, action:, tag:)
199
- directory = File.join(root, tag)
200
- directory = File.join(directory, 'nested workspace') if task[:environment] == 'nested-pretty-readonly'
201
- FileUtils.mkdir_p(directory)
202
- input = File.join(directory, 'input.json')
203
- output = File.join(directory, 'result.json')
204
- raise 'task workspace reused' if File.exist?(input) || File.exist?(output)
205
-
206
- File.write(input, task[:environment] == 'nested-pretty-readonly' ? JSON.pretty_generate(task[:input]) : JSON.generate(task[:input]))
207
- File.chmod(0o444, input) if task[:environment] == 'nested-pretty-readonly'
208
- input_digest = Digest::SHA256.file(input).hexdigest
209
- started = clock
210
- result = action.handler.call(input: input, output: output)
211
- duration = clock - started
212
- completed = score(path: output, expected: task[:expected]) && Digest::SHA256.file(input).hexdigest == input_digest
213
- {
214
- task_id: task[:id], family: task[:family], action: action.name,
215
- completed: completed, claimed_success: result[:ok] == true,
216
- artifact_check_score: completed ? 1.0 : 0.0,
217
- false_success: result[:ok] == true && !completed,
218
- checker: completed ? 'exact JSON answer and unchanged input' : 'missing/wrong artifact or changed input',
219
- returned_text: result[:text], elapsed_seconds: duration, tool_calls: 1,
220
- input_sha256: input_digest, artifact_sha256: File.file?(output) ? Digest::SHA256.file(output).hexdigest : nil,
221
- artifact_content: File.file?(output) && !File.symlink?(output) ? File.read(output) : nil
222
- }
223
- end
224
-
225
- def self.fingerprint(root:)
226
- Dir[File.join(root, '.pwn', '**', '*')].select { |path| File.file?(path) }.sort.to_h do |path|
227
- [path.delete_prefix("#{root}/"), Digest::SHA256.file(path).hexdigest]
228
- end
229
- end
230
-
231
- def self.run_arm(root:, mode:, snapshot_dir: nil)
232
- policy = PWN::AI::Agent::Policy
233
- registry = PWN::AI::Agent::Registry
234
- policy.reset
235
- PWN::Env[:ai][:agent][:policy] = mode == :on
236
- train = fixtures(split: :train)
237
- training_started = clock
238
- training_rows = []
239
- # Balanced, fixed exploration schedule: no answer-based action selection.
240
- 6.times do |round|
241
- train.each do |task|
242
- registry.all.select { |entry| entry.toolset == task[:family].to_s }.each do |action|
243
- tag = "#{mode}/train/#{round}/#{task[:id]}/#{action.name}"
244
- policy.begin_episode(session_id: tag, request: query(family: task[:family]))
245
- row = execute(root: root, task: task, action: action, tag: tag)
246
- policy.observe_step(session_id: tag, action: action.name, action_id: tag, ok: row[:completed], duration: row[:elapsed_seconds])
247
- update = policy.finish(
248
- session_id: tag, score: row[:completed] ? 1.0 : 0.0, confidence: 1.0, verdict: row[:completed] ? 'solved' : 'wrong',
249
- attribution: { source: 'controlled_comparison', verified_action_ids: [tag] }
250
- )
251
- raise "Policy.finish failed: #{update.inspect}" if update.nil? || update[:error]
252
-
253
- training_rows << row.merge(policy_update: update)
254
- end
255
- end
256
- end
257
- training = summarize(rows: training_rows, elapsed: clock - training_started)
258
- if snapshot_dir
259
- # Metadata timestamps are not routing state; normalize only exported
260
- # benchmark snapshots so repeated runs have reproducible content hashes.
261
- snapshot = policy.load.merge(updated_at: nil)
262
- File.open(File.join(snapshot_dir, "#{mode}.json"), File::WRONLY | File::CREAT | File::EXCL, 0o600) { |file| file.write(JSON.generate(snapshot)) }
263
- end
264
- evaluate_arm(root: root, mode: mode, train: train).merge(training: training)
265
- end
266
-
267
- def self.evaluate_arm(root:, mode:, train:, seed: 0)
268
- policy = PWN::AI::Agent::Policy
269
- registry = PWN::AI::Agent::Registry
270
- stats = policy.stats
271
- before = fingerprint(root: root)
272
- # Held-out data is materialized only AFTER all training finishes.
273
- heldout = fixtures(split: :heldout, seed: seed)
274
- disjoint = !train.map { |task| task[:input] }.intersect?(heldout.map { |task| task[:input] })
275
- raise 'training/evaluation input overlap' unless disjoint
276
-
277
- evaluation_started = clock
278
- evaluation_rows = heldout.flat_map do |task|
279
- attempts = []
280
- 3.times do |attempt|
281
- pool = registry.all.select { |entry| entry.toolset == task[:family].to_s }
282
- # No begin/observe/finish during evaluation; Registry queries the
283
- # learned table with its ordinary public fallback state.
284
- ranked = registry.rank(query: query(family: task[:family]), entries: pool, preference: [])
285
- raise 'Registry returned no action' if ranked.empty?
286
-
287
- row = execute(root: root, task: task, action: ranked.first, tag: "#{mode}/heldout/#{task[:id]}/#{attempt}")
288
- attempts << row.merge(attempt: attempt + 1, ranking: ranked.map(&:name))
289
- break if row[:completed]
290
- end
291
- attempts
292
- end
293
- evaluation = summarize(rows: evaluation_rows, elapsed: clock - evaluation_started)
294
- controls = heldout.flat_map do |task|
295
- names = task[:family] == :numbers ? %w[c_numbers_claim control_wrong_artifact] : %w[c_inventory_claim control_wrong_artifact]
296
- names.map do |name|
297
- execute(root: root, task: task, action: registry.lookup(name: name), tag: "#{mode}/controls/#{task[:id]}/#{name}")
298
- end
299
- end
300
- positive_controls = heldout.map do |task|
301
- name = task[:family] == :numbers ? 'b_numeric_sort' : 'a_active_filter'
302
- execute(root: root, task: task, action: registry.lookup(name: name), tag: "#{mode}/positive/#{task[:id]}")
303
- end
304
- frozen = before == fingerprint(root: root)
305
- raise 'held-out evaluation changed persisted policy' unless frozen
306
- raise 'a negative control defeated the independent scorer' unless controls.all? { |row| row[:false_success] && !row[:completed] }
307
- raise 'a positive control failed the independent scorer' unless positive_controls.all? { |row| row[:completed] }
308
-
309
- { disjoint_inputs: disjoint, policy_frozen: frozen, policy_stats: stats, environment: heldout.first[:environment],
310
- evaluation: evaluation, negative_controls: controls, positive_controls: positive_controls,
311
- fixtures: { training: train, heldout: heldout },
312
- policy_sha256: before, train_ids: train.map { |task| task[:id] }, heldout_ids: heldout.map { |task| task[:id] } }
313
- end
314
-
315
- def self.summarize(rows:, elapsed:)
316
- tasks = rows.group_by { |row| row[:task_id] }
317
- completed = tasks.count { |_, attempts| attempts.any? { |row| row[:completed] } }
318
- failures = Hash.new(0)
319
- rows.each do |row|
320
- signature = [row[:family], row[:action], row[:checker]]
321
- row[:repeated_mistake] = !row[:completed] && failures[signature].positive?
322
- failures[signature] += 1 unless row[:completed]
323
- end
324
- {
325
- tasks: tasks.length, completed: completed, completion_rate: completed.to_f / tasks.length,
326
- artifact_check_score: rows.sum { |row| row[:artifact_check_score] } / rows.length,
327
- false_successes: rows.count { |row| row[:false_success] },
328
- false_success_rate: rows.count { |row| row[:false_success] }.to_f / rows.length,
329
- repeated_mistakes: rows.count { |row| row[:repeated_mistake] },
330
- tool_calls: rows.sum { |row| row[:tool_calls] }, elapsed_seconds: elapsed,
331
- llm_calls: 0, tokens: 0, monetary_cost: nil,
332
- cost_note: 'No provider invoked; monetary cost not estimated. Local compute cost is not priced.',
333
- rows: rows
334
- }
335
- end
336
- end
337
-
338
- if $PROGRAM_NAME == __FILE__
339
- options = {}
340
- parser = OptionParser.new do |opts|
341
- opts.banner = 'Usage: ruby scripts/benchmark_policy.rb [--self-check] [--output /tmp/report.json]'
342
- opts.on('--self-check', 'Run scorer and end-to-end assertions') { options[:self_check] = true }
343
- opts.on('--output PATH', 'Write the complete measured JSON report') { |path| options[:output] = path }
344
- opts.on('--snapshot-evaluation', 'Internal fixed held-out worker; JSON request on stdin') { options[:snapshot_evaluation] = true }
345
- opts.on('--heldout', 'Opt in to two frozen independent snapshot evaluations (never promotes)') { options[:heldout] = true }
346
- opts.on('--snapshot-dir PATH', 'Export off/on snapshots to a NEW directory under /tmp') { |path| options[:snapshot_dir] = path }
347
- end
348
- parser.parse!
349
- abort parser.to_s unless ARGV.empty?
350
- abort '--heldout requires --snapshot-dir' if options[:heldout] && !options[:snapshot_dir]
351
- if options[:snapshot_dir]
352
- path = File.expand_path(options[:snapshot_dir])
353
- abort '--snapshot-dir must be a new directory beneath /tmp, without symlink parents' unless path.start_with?('/tmp/') && File.realpath(File.dirname(path)) == File.dirname(path) && !File.exist?(path)
354
- Dir.mkdir(path, 0o700)
355
- options[:snapshot_dir] = path
356
- end
357
- if options[:snapshot_evaluation]
358
- puts JSON.generate(PolicyBenchmark.evaluate_snapshots(request: JSON.parse($stdin.read, symbolize_names: true)))
359
- elsif options[:self_check]
360
- PolicyBenchmark.self_check
361
- else
362
- report = PolicyBenchmark.run(snapshot_dir: options[:snapshot_dir])
363
- if options[:heldout]
364
- require_relative '../lib/pwn/ai/agent/policy_evaluation'
365
- report[:heldout] = [0, 1].map do |seed|
366
- PWN::AI::Agent::PolicyEvaluation.evaluate(
367
- baseline: File.join(options[:snapshot_dir], 'off.json'),
368
- candidate: File.join(options[:snapshot_dir], 'on.json'), seed: seed
369
- )
370
- end
371
- end
372
- json = JSON.pretty_generate(report)
373
- File.write(options[:output], "#{json}\n") if options[:output]
374
- puts json
375
- end
376
- end
@@ -1,58 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Run only the validation gate, never installation. Keep one bounded, private
4
- # log even when a terminal disconnects or the child exits without a summary.
5
- require 'fileutils'
6
- require 'open3'
7
-
8
- abort 'Usage: ruby scripts/build_gate.rb command [args...]' if ARGV.empty?
9
-
10
- limit = 1_048_576
11
- path = File.join(Dir.home, '.pwn', 'logs', 'build-gate.log')
12
- FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
13
- File.open(path, File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |log|
14
- abort "Build gate already running; log: #{path}" unless log.flock(File::LOCK_EX | File::LOCK_NB)
15
-
16
- log.chmod(0o600)
17
- log.truncate(0)
18
- log.sync = true
19
- $stdout.sync = true
20
- append = lambda do |bytes|
21
- if log.size + bytes.bytesize > limit
22
- log.rewind
23
- retained = (log.read + bytes).byteslice(-limit, limit)
24
- log.rewind
25
- log.truncate(0)
26
- log.write(retained)
27
- else
28
- log.seek(0, IO::SEEK_END)
29
- log.write(bytes)
30
- end
31
- end
32
- emit = lambda do |bytes|
33
- append.call(bytes)
34
- begin
35
- $stdout.write(bytes)
36
- rescue Errno::EPIPE, IOError
37
- # Continue draining the child into the durable log after stream loss.
38
- nil
39
- end
40
- end
41
- emit.call("Build gate log: #{path} (last #{limit} bytes; replaced on next run)\n")
42
- emit.call("Gate launcher: uid=#{Process.uid} ruby=#{RUBY_VERSION} cwd=#{Dir.pwd} HOME=#{Dir.home}\n")
43
- status = nil
44
- Open3.popen2e(*ARGV) do |input, output, waiter|
45
- input.close
46
- loop { emit.call(output.readpartial(16_384)) }
47
- rescue EOFError
48
- status = waiter.value
49
- end
50
- result = status.signaled? ? "signal=#{Signal.signame(status.termsig)}" : "exit=#{status.exitstatus}"
51
- emit.call("\nBuild gate #{result}\n")
52
- unless status.success?
53
- log.rewind
54
- tail = log.read.byteslice(-16_384, 16_384) || log.tap(&:rewind).read
55
- warn "\nBuild gate failed (#{result}). Build gate log: #{path}\n#{tail}"
56
- end
57
- exit(status.exitstatus || (128 + status.termsig))
58
- end
@@ -1,73 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'spec_helper'
4
- require 'open3'
5
- require 'tmpdir'
6
-
7
- RSpec.describe 'build gate diagnostics' do
8
- it 'reports the RSpec child signal rather than only a generic command failure' do
9
- Dir.mktmpdir('pwn-rake-signal-') do |home|
10
- spec = File.join(home, 'terminated_spec.rb')
11
- File.write(spec, "RSpec.describe('fixture') { it('terminates') { Process.kill('KILL', Process.pid) } }\n")
12
- output, status = Open3.capture2e({ 'HOME' => home, 'SPEC' => spec, 'SPEC_OPTS' => nil }, 'bundle', 'exec', 'rake', 'spec')
13
- expect(status.success?).to be(false)
14
- expect(output).to match(/RSpec child failed: (signal=KILL|exit=137)/)
15
- end
16
- end
17
-
18
- it 'retains the first failure details even if a later example terminates RSpec' do
19
- Dir.mktmpdir('pwn-rake-failure-') do |home|
20
- spec = File.join(home, 'terminated_spec.rb')
21
- File.write(spec, "RSpec.describe('fixture') { it('fails first') { expect(1).to eq(2) }; it('terminates') { Process.kill('KILL', Process.pid) } }\n")
22
- output, status = Open3.capture2e({ 'HOME' => home, 'SPEC' => spec, 'SPEC_OPTS' => nil }, 'bundle', 'exec', 'rake', 'spec')
23
- expect(status.success?).to be(false)
24
- expect(output).to include('fixture fails first', 'expected: 2', 'got: 1')
25
- end
26
- end
27
-
28
- it 'keeps a bounded log and preserves successful exit after large output' do
29
- Dir.mktmpdir('pwn-build-bound-') do |home|
30
- script = File.expand_path('../../scripts/build_gate.rb', __dir__)
31
- output, status = Open3.capture2e({ 'HOME' => home }, RbConfig.ruby, script, RbConfig.ruby, '-e', 'puts "x" * 2_000_000; puts "finished fixture"')
32
- expect(status.success?).to be(true)
33
- expect(output).to include('finished fixture')
34
- log = File.join(home, '.pwn', 'logs', 'build-gate.log')
35
- expect(File.size(log)).to be <= 1_048_576
36
- expect(File.read(log)).to include('finished fixture', 'exit=0')
37
- end
38
- end
39
-
40
- it 'records a directly signalled gate without inventing a summary' do
41
- Dir.mktmpdir('pwn-build-signal-') do |home|
42
- script = File.expand_path('../../scripts/build_gate.rb', __dir__)
43
- output, status = Open3.capture2e({ 'HOME' => home }, RbConfig.ruby, script, RbConfig.ruby, '-e', 'Process.kill("TERM", Process.pid)')
44
- expect(status.exitstatus).to eq(143)
45
- expect(output).to include('signal=TERM')
46
- expect(File.read(File.join(home, '.pwn', 'logs', 'build-gate.log'))).to include('signal=TERM')
47
- end
48
- end
49
-
50
- it 'finishes recording when both terminal streams disconnect' do
51
- Dir.mktmpdir('pwn-build-disconnect-') do |home|
52
- script = File.expand_path('../../scripts/build_gate.rb', __dir__)
53
- reader, writer = IO.pipe
54
- reader.close
55
- pid = Process.spawn({ 'HOME' => home }, RbConfig.ruby, script, RbConfig.ruby, '-e', 'warn "Failures: disconnected fixture"; exit 23', out: writer, err: writer)
56
- writer.close
57
- expect(Process.wait2(pid).last.exitstatus).to eq(23)
58
- expect(File.read(File.join(home, '.pwn', 'logs', 'build-gate.log'))).to include('Failures: disconnected fixture', 'exit=23')
59
- end
60
- end
61
-
62
- it 'preserves merged failure output and the gate exit status outside the terminal stream' do
63
- Dir.mktmpdir('pwn-build-gate-') do |home|
64
- script = File.expand_path('../../scripts/build_gate.rb', __dir__)
65
- output, status = Open3.capture2e({ 'HOME' => home }, RbConfig.ruby, script, RbConfig.ruby, '-e', '$stdout.sync = true; puts "F.F"; warn "Failures: fixture assertion"; exit 23')
66
- expect(status.exitstatus).to eq(23)
67
- expect(output).to include('Failures: fixture assertion', 'exit=23', 'Build gate log:')
68
- log = File.join(home, '.pwn', 'logs', 'build-gate.log')
69
- expect(File.read(log)).to include('F.F', 'Failures: fixture assertion', 'exit=23')
70
- expect(File.stat(log).mode & 0o777).to eq(0o600)
71
- end
72
- end
73
- end