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.
Files changed (264) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/Gemfile +8 -8
  4. data/README.md +37 -0
  5. data/Rakefile +19 -1
  6. data/bin/pwn +2 -1
  7. data/bin/pwn_setup +1 -1
  8. data/bin/pwn_web_cache_deception +35 -7
  9. data/build_gem.sh +1 -1
  10. data/documentation/AI-Integration.md +7 -5
  11. data/documentation/Agent-Tool-Registry.md +2 -2
  12. data/documentation/Backup-Retention.md +82 -0
  13. data/documentation/Configuration.md +10 -6
  14. data/documentation/Contributing.md +1 -1
  15. data/documentation/Cron.md +64 -35
  16. data/documentation/Diagrams.md +2 -2
  17. data/documentation/General-PWN-Usage.md +4 -3
  18. data/documentation/Home.md +6 -5
  19. data/documentation/How-PWN-Works.md +7 -6
  20. data/documentation/Installation.md +254 -10
  21. data/documentation/Mission-Cancellation.md +134 -0
  22. data/documentation/Operability.md +17 -0
  23. data/documentation/Persistence.md +8 -6
  24. data/documentation/Plugin-Degradation.md +12 -0
  25. data/documentation/Plugins.md +3 -2
  26. data/documentation/Reinforcement-Learning.md +5 -6
  27. data/documentation/Runtime-Source-Protection.md +162 -0
  28. data/documentation/Transparent-Browser.md +11 -0
  29. data/documentation/Troubleshooting.md +3 -2
  30. data/documentation/What-is-PWN.md +2 -2
  31. data/documentation/YubiKey-Vaults.md +183 -0
  32. data/documentation/YubiKey.md +339 -0
  33. data/documentation/diagrams/cron-scheduling.svg +130 -128
  34. data/documentation/diagrams/dot/cron-scheduling.dot +5 -5
  35. data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
  36. data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
  37. data/documentation/diagrams/persistence-filesystem.svg +172 -142
  38. data/documentation/diagrams/plugin-ecosystem.svg +64 -64
  39. data/documentation/pwn-ai-Agent.md +10 -4
  40. data/etc/default_cron/session_cleanup.rb +1245 -22
  41. data/etc/default_skills/pwn/ai/agent/mission_namespace/SKILL.md +21 -0
  42. data/etc/default_skills/pwn/ai/agent/tools/browser_act/SKILL.md +45 -0
  43. data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +27 -26
  44. data/etc/default_skills/pwn/ai/agent/tools/recon_run/SKILL.md +25 -15
  45. data/etc/default_skills/pwn/ai/agent/tools/workspace_tool/SKILL.md +24 -0
  46. data/etc/default_skills/pwn/ai/server_tools/SKILL.md +25 -0
  47. data/etc/default_skills/pwn/backups/SKILL.md +54 -0
  48. data/etc/default_skills/pwn/plugins/recon/SKILL.md +12 -1
  49. data/etc/default_skills/pwn/plugins/yubikey/SKILL.md +157 -0
  50. data/etc/default_skills/pwn/plugins/yubikey/fido/SKILL.md +189 -0
  51. data/etc/default_skills/pwn/plugins/yubikey/hsmauth/SKILL.md +83 -0
  52. data/etc/default_skills/pwn/plugins/yubikey/management/SKILL.md +80 -0
  53. data/etc/default_skills/pwn/plugins/yubikey/oath/SKILL.md +115 -0
  54. data/etc/default_skills/pwn/plugins/yubikey/openpgp/SKILL.md +144 -0
  55. data/etc/default_skills/pwn/plugins/yubikey/otp/SKILL.md +97 -0
  56. data/etc/default_skills/pwn/plugins/yubikey/piv/SKILL.md +110 -0
  57. data/etc/default_skills/pwn/plugins/yubikey/protocol/SKILL.md +21 -0
  58. data/etc/default_skills/pwn/plugins/yubikey/security_domain/SKILL.md +75 -0
  59. data/etc/default_skills/pwn/plugins/yubikey/security_domain/scp/SKILL.md +46 -0
  60. data/etc/default_skills/pwn/plugins/yubikey/transport/SKILL.md +98 -0
  61. data/etc/default_skills/pwn/redaction/SKILL.md +13 -1
  62. data/lib/pwn/ai/agent/dispatch.rb +26 -15
  63. data/lib/pwn/ai/agent/extrospection.rb +6 -7
  64. data/lib/pwn/ai/agent/learning.rb +62 -25
  65. data/lib/pwn/ai/agent/loop.rb +17 -1
  66. data/lib/pwn/ai/agent/mission_namespace.rb +331 -0
  67. data/lib/pwn/ai/agent/policy.rb +5 -0
  68. data/lib/pwn/ai/agent/policy_evaluation.rb +8 -8
  69. data/lib/pwn/ai/agent/prompt_builder.rb +13 -2
  70. data/lib/pwn/ai/agent/request_runtime.rb +122 -18
  71. data/lib/pwn/ai/agent/reward.rb +5 -3
  72. data/lib/pwn/ai/agent/skill_consolidation.rb +3 -2
  73. data/lib/pwn/ai/agent/skill_review.rb +9 -5
  74. data/lib/pwn/ai/agent/swarm.rb +35 -16
  75. data/lib/pwn/ai/agent/tools/browser_act.rb +32 -0
  76. data/lib/pwn/ai/agent/tools/loot_query.rb +11 -1
  77. data/lib/pwn/ai/agent/tools/recon_run.rb +11 -1
  78. data/lib/pwn/ai/agent/tools/workspace_tool.rb +29 -0
  79. data/lib/pwn/ai/agent/turn_finalizer.rb +6 -2
  80. data/lib/pwn/ai/agent.rb +1 -0
  81. data/lib/pwn/ai/anthropic.rb +8 -33
  82. data/lib/pwn/ai/cli.rb +34 -5
  83. data/lib/pwn/ai/gemini.rb +1 -0
  84. data/lib/pwn/ai/grok.rb +39 -28
  85. data/lib/pwn/ai/ollama.rb +4 -2
  86. data/lib/pwn/ai/open_ai.rb +11 -32
  87. data/lib/pwn/ai/open_web_ui.rb +62 -4
  88. data/lib/pwn/ai/router.rb +53 -3
  89. data/lib/pwn/ai/server_tools.rb +231 -0
  90. data/lib/pwn/ai.rb +1 -0
  91. data/lib/pwn/backups.rb +347 -0
  92. data/lib/pwn/config.rb +70 -39
  93. data/lib/pwn/cron.rb +120 -49
  94. data/lib/pwn/migrate.rb +150 -68
  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/binary_parser.rb +5 -4
  100. data/lib/pwn/plugins/ghidra.rb +4 -3
  101. data/lib/pwn/plugins/google_workspace.rb +4 -21
  102. data/lib/pwn/plugins/jobs.rb +1 -1
  103. data/lib/pwn/plugins/log.rb +21 -3
  104. data/lib/pwn/plugins/preflight_checker.rb +34 -9
  105. data/lib/pwn/plugins/recon.rb +4 -1
  106. data/lib/pwn/plugins/repl/ai.rb +305 -27
  107. data/lib/pwn/plugins/repl/ai_console.rb +273 -89
  108. data/lib/pwn/plugins/repl/ai_swarm.rb +181 -62
  109. data/lib/pwn/plugins/repl/mesh.rb +3 -16
  110. data/lib/pwn/plugins/repl/vault.rb +4 -15
  111. data/lib/pwn/plugins/repl.rb +1 -216
  112. data/lib/pwn/plugins/sandbox/driver.rb +4 -0
  113. data/lib/pwn/plugins/transparent_browser.rb +215 -0
  114. data/lib/pwn/plugins/vault.rb +412 -135
  115. data/lib/pwn/plugins/yubikey/fido.rb +1288 -0
  116. data/lib/pwn/plugins/yubikey/hsmauth.rb +439 -0
  117. data/lib/pwn/plugins/yubikey/management.rb +686 -0
  118. data/lib/pwn/plugins/yubikey/oath.rb +766 -0
  119. data/lib/pwn/plugins/yubikey/openpgp.rb +1063 -0
  120. data/lib/pwn/plugins/yubikey/otp.rb +897 -0
  121. data/lib/pwn/plugins/yubikey/piv.rb +1315 -0
  122. data/lib/pwn/plugins/yubikey/protocol.rb +341 -0
  123. data/lib/pwn/plugins/yubikey/security_domain/scp.rb +482 -0
  124. data/lib/pwn/plugins/yubikey/security_domain.rb +464 -0
  125. data/lib/pwn/plugins/yubikey/transport.rb +1017 -0
  126. data/lib/pwn/plugins/yubikey.rb +844 -0
  127. data/lib/pwn/plugins.rb +1 -0
  128. data/lib/pwn/redaction.rb +69 -12
  129. data/lib/pwn/session_trace.rb +4 -0
  130. data/lib/pwn/sessions.rb +58 -16
  131. data/lib/pwn/setup.rb +123 -41
  132. data/lib/pwn/version.rb +1 -1
  133. data/lib/pwn/www/app_cobalt_io.rb +16 -5
  134. data/lib/pwn/www/bug_crowd.rb +16 -5
  135. data/lib/pwn/www/coinbase_pro.rb +16 -5
  136. data/lib/pwn/www/github.rb +30 -14
  137. data/lib/pwn/www/paypal.rb +15 -4
  138. data/lib/pwn/www/synack.rb +16 -5
  139. data/lib/pwn/www/twitter.rb +17 -6
  140. data/lib/pwn.rb +1 -0
  141. data/scripts/build_gate.rb +58 -0
  142. data/spec/documentation/installation_md_spec.rb +1 -1
  143. data/spec/fixtures/yubikey/apdu_frames.json +162 -0
  144. data/spec/integration/authentication_helper_oath_spec.rb +117 -0
  145. data/spec/integration/build_gate_spec.rb +73 -0
  146. data/spec/integration/controller_spill_spec.rb +48 -0
  147. data/spec/integration/harness_process_spec.rb +97 -0
  148. data/spec/integration/mission_cancel_spec.rb +79 -0
  149. data/spec/integration/mission_curses_pty_spec.rb +44 -0
  150. data/spec/integration/persistence_roundtrip_spec.rb +1 -7
  151. data/spec/integration/readme_security_spec.rb +47 -0
  152. data/spec/integration/web_cache_deception_yubikey_spec.rb +159 -0
  153. data/spec/lib/pwn/ai/agent/dispatch_spec.rb +6 -3
  154. data/spec/lib/pwn/ai/agent/dispatch_vault_safety_spec.rb +86 -0
  155. data/spec/lib/pwn/ai/agent/injection_guard_spec.rb +2 -0
  156. data/spec/lib/pwn/ai/agent/learning_spec.rb +4 -4
  157. data/spec/lib/pwn/ai/agent/loop_artifacts_spec.rb +5 -0
  158. data/spec/lib/pwn/ai/agent/loop_spec.rb +104 -29
  159. data/spec/lib/pwn/ai/agent/mission_namespace_spec.rb +138 -0
  160. data/spec/lib/pwn/ai/agent/policy_spec.rb +27 -0
  161. data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +48 -0
  162. data/spec/lib/pwn/ai/agent/shell_search_effect_spec.rb +44 -0
  163. data/spec/lib/pwn/ai/agent/solve_spec.rb +6 -4
  164. data/spec/lib/pwn/ai/agent/tools/browser_act_spec.rb +44 -0
  165. data/spec/lib/pwn/ai/agent/tools/loot_query_yubikey_spec.rb +55 -0
  166. data/spec/lib/pwn/ai/agent/tools/recon_run_yubikey_spec.rb +43 -0
  167. data/spec/lib/pwn/ai/agent/tools/workspace_tool_spec.rb +20 -0
  168. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +7 -0
  169. data/spec/lib/pwn/ai/anthropic_vault_enrollment_spec.rb +37 -0
  170. data/spec/lib/pwn/ai/cli_spec.rb +6 -9
  171. data/spec/lib/pwn/ai/oauth_vault_persistence_spec.rb +195 -0
  172. data/spec/lib/pwn/ai/open_ai_spec.rb +2 -2
  173. data/spec/lib/pwn/ai/open_web_ui_workspace_spec.rb +36 -0
  174. data/spec/lib/pwn/ai/router_spec.rb +31 -3
  175. data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
  176. data/spec/lib/pwn/backups_spec.rb +114 -0
  177. data/spec/lib/pwn/config_redaction_yubikey_spec.rb +28 -0
  178. data/spec/lib/pwn/config_yubikey_spec.rb +92 -0
  179. data/spec/lib/pwn/cron_spec.rb +113 -6
  180. data/spec/lib/pwn/migrate_spec.rb +108 -5
  181. data/spec/lib/pwn/migrate_yubikey_spec.rb +128 -0
  182. data/spec/lib/pwn/module_skills_spec.rb +8 -0
  183. data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +230 -0
  184. data/spec/lib/pwn/plugins/authentication_helper_native_yubikey_spec.rb +40 -0
  185. data/spec/lib/pwn/plugins/authentication_helper_yubikey_spec.rb +90 -0
  186. data/spec/lib/pwn/plugins/capability_broker/daemon_spec.rb +1 -1
  187. data/spec/lib/pwn/plugins/google_workspace_spec.rb +17 -35
  188. data/spec/lib/pwn/plugins/log_spec.rb +7 -0
  189. data/spec/lib/pwn/plugins/log_yubikey_spec.rb +47 -0
  190. data/spec/lib/pwn/plugins/preflight_checker_native_yubikey_spec.rb +19 -0
  191. data/spec/lib/pwn/plugins/preflight_checker_yubikey_spec.rb +55 -0
  192. data/spec/lib/pwn/plugins/recon_yubikey_spec.rb +43 -0
  193. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +125 -78
  194. data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
  195. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +30 -16
  196. data/spec/lib/pwn/plugins/repl/ai_vault_edit_yubikey_spec.rb +103 -0
  197. data/spec/lib/pwn/plugins/repl/ai_yubikey_spec.rb +48 -0
  198. data/spec/lib/pwn/plugins/repl/mesh_vault_persistence_spec.rb +149 -0
  199. data/spec/lib/pwn/plugins/repl/vault_yaml_serialization_spec.rb +11 -0
  200. data/spec/lib/pwn/plugins/repl/vault_yubikey_spec.rb +39 -0
  201. data/spec/lib/pwn/plugins/repl_spec.rb +56 -5
  202. data/spec/lib/pwn/plugins/transparent_browser_jev_spec.rb +46 -0
  203. data/spec/lib/pwn/plugins/vault_native_yubikey_spec.rb +29 -0
  204. data/spec/lib/pwn/plugins/vault_publication_safety_spec.rb +258 -0
  205. data/spec/lib/pwn/plugins/vault_store_safety_spec.rb +241 -0
  206. data/spec/lib/pwn/plugins/vault_yubikey_safety_spec.rb +168 -0
  207. data/spec/lib/pwn/plugins/vault_yubikey_spec.rb +128 -0
  208. data/spec/lib/pwn/plugins/vault_yubikey_vector_spec.rb +45 -0
  209. data/spec/lib/pwn/plugins/yubikey/fido_spec.rb +854 -0
  210. data/spec/lib/pwn/plugins/yubikey/hsmauth_spec.rb +235 -0
  211. data/spec/lib/pwn/plugins/yubikey/management_spec.rb +277 -0
  212. data/spec/lib/pwn/plugins/yubikey/oath_spec.rb +349 -0
  213. data/spec/lib/pwn/plugins/yubikey/openpgp_spec.rb +810 -0
  214. data/spec/lib/pwn/plugins/yubikey/otp_spec.rb +337 -0
  215. data/spec/lib/pwn/plugins/yubikey/piv_spec.rb +646 -0
  216. data/spec/lib/pwn/plugins/yubikey/protocol_secure_spec.rb +28 -0
  217. data/spec/lib/pwn/plugins/yubikey/protocol_spec.rb +149 -0
  218. data/spec/lib/pwn/plugins/yubikey/protocol_vectors_spec.rb +18 -0
  219. data/spec/lib/pwn/plugins/yubikey/security_domain/scp_spec.rb +256 -0
  220. data/spec/lib/pwn/plugins/yubikey/security_domain/support.rb +153 -0
  221. data/spec/lib/pwn/plugins/yubikey/security_domain_spec.rb +296 -0
  222. data/spec/lib/pwn/plugins/yubikey/transport_spec.rb +760 -0
  223. data/spec/lib/pwn/plugins/yubikey_security_spec.rb +260 -0
  224. data/spec/lib/pwn/plugins/yubikey_spec.rb +209 -0
  225. data/spec/lib/pwn/plugins/yubikey_terminal_spec.rb +147 -0
  226. data/spec/lib/pwn/redaction_spec.rb +13 -10
  227. data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
  228. data/spec/lib/pwn/session_cleanup_concurrency_spec.rb +201 -0
  229. data/spec/lib/pwn/session_cleanup_default_spec.rb +167 -2
  230. data/spec/lib/pwn/session_cleanup_legacy_spec.rb +101 -0
  231. data/spec/lib/pwn/session_cleanup_payload_spec.rb +335 -2
  232. data/spec/lib/pwn/session_cleanup_recovery_spec.rb +403 -0
  233. data/spec/lib/pwn/sessions_spec.rb +112 -0
  234. data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
  235. data/spec/lib/pwn/setup_spec.rb +3 -1
  236. data/spec/lib/pwn/setup_vault_yubikey_spec.rb +61 -0
  237. data/spec/lib/pwn/setup_yubikey_spec.rb +40 -0
  238. data/spec/lib/pwn/www/login_yubikey_spec.rb +273 -0
  239. data/spec/spec_helper.rb +15 -0
  240. data/spec/support/gate_failure_formatter.rb +14 -0
  241. data/spec/support/harness/cli_faults.rb +13 -9
  242. data/spec/support/harness/controller_spill.rb +26 -0
  243. data/spec/support/harness/controller_state.rb +54 -0
  244. data/spec/support/harness/evaluate.rb +12 -1
  245. data/spec/support/harness/experiments.rb +1 -1
  246. data/spec/support/harness/fake_provider.rb +22 -6
  247. data/spec/support/harness/fault_preload.rb +3 -1
  248. data/spec/support/harness/mission_console.rb +54 -0
  249. data/spec/support/harness/mission_curses_pty.rb +41 -0
  250. data/spec/support/harness/mission_dispatch.rb +30 -0
  251. data/spec/support/harness/mission_fixture_preload.rb +24 -0
  252. data/spec/support/harness/mission_job_lifetime.rb +25 -0
  253. data/spec/support/harness/mission_legacy.rb +25 -0
  254. data/spec/support/harness/mission_normal_exit.rb +34 -0
  255. data/spec/support/harness/mission_swarm.rb +23 -0
  256. data/spec/support/harness/mission_unsupported.rb +29 -0
  257. data/spec/support/harness/package_acceptance.rb +33 -5
  258. data/spec/support/harness/session_acceptance.rb +4 -4
  259. data/spec/support/harness/swarm_acceptance.rb +1 -1
  260. data/spec/support/harness/swarm_control.rb +44 -0
  261. data/spec/support/mission_namespace.rb +14 -0
  262. data/spec/support/mission_pipe_queue.rb +33 -0
  263. data/third_party/pwn_rdoc.jsonl +599 -15
  264. metadata +140 -17
@@ -101,7 +101,14 @@ module PWN
101
101
  # Plan by default; no provider calls, vault reads, transcript logging or deletion.
102
102
  public_class_method def self.migrate_evidence(opts = {})
103
103
  dry = opts[:dry_run] != false
104
- lock = learning_lock unless dry
104
+ max_sessions = opts[:max_sessions]
105
+ stop = opts[:stop]
106
+ raise ArgumentError, 'max_sessions must be positive' if max_sessions && (!max_sessions.is_a?(Integer) || max_sessions <= 0)
107
+
108
+ leases = []
109
+ lock = learning_lock(nonblocking: opts[:nonblocking]) unless dry
110
+ return { dry_run: false, deferred: 'learning_writer_active', migrated_rows: 0 } if lock == :busy
111
+
105
112
  original = File.exist?(LEARNING_FILE) ? evidence_read(path: LEARNING_FILE) : ''
106
113
  malformed = 0
107
114
  rows = original.lines.filter_map do |line|
@@ -116,9 +123,13 @@ module PWN
116
123
 
117
124
  report = { dry_run: dry, rows: rows.length, malformed_rows: malformed, session_refs: 0, migratable_sessions: 0,
118
125
  missing_sessions: 0, malformed_sessions: 0, malformed_refs: 0, verified_rows: 0,
119
- estimated_source_bytes: 0, migrated_rows: 0 }
126
+ estimated_source_bytes: 0, migrated_rows: 0, deferred_active_sessions: 0 }
120
127
  snapshots = {}
121
128
  rows.each do |row|
129
+ if stop&.call || (max_sessions && report[:migratable_sessions] >= max_sessions)
130
+ report[:bounded] = true
131
+ break
132
+ end
122
133
  sid = row[:session_id]
123
134
  next if sid.nil? || sid == ''
124
135
 
@@ -140,6 +151,22 @@ module PWN
140
151
  next
141
152
  end
142
153
  begin
154
+ # Keep the permanent exclusive lease through index publication;
155
+ # a point-in-time active? check alone races subsequent activation.
156
+ if dry
157
+ if PWN::Sessions.active?(session_id: sid)
158
+ report[:deferred_active_sessions] += 1
159
+ next
160
+ end
161
+ else
162
+ lease = File.open(PWN::Sessions.support_path(kind: :lease, session_id: sid), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) # rubocop:disable Style/FileOpen -- released together after commit
163
+ leases << lease
164
+ unless lease.flock(File::LOCK_EX | File::LOCK_NB)
165
+ lease.close
166
+ report[:deferred_active_sessions] += 1
167
+ next
168
+ end
169
+ end
143
170
  body = evidence_read(path: path)
144
171
  transcript = body.lines.map { |line| JSON.parse(line, symbolize_names: true) }
145
172
  raise IOError, 'Malformed source transcript' if transcript.empty? || !transcript.all?(Hash)
@@ -179,7 +206,8 @@ module PWN
179
206
 
180
207
  evidence_commit(rows: rows, original: original, snapshots: snapshots, report: report)
181
208
  ensure
182
- learning_unlock(lock: lock) if lock
209
+ leases&.each { |lease| lease.close unless lease.closed? }
210
+ learning_unlock(lock: lock) if lock && lock != :busy
183
211
  end
184
212
 
185
213
  private_class_method def self.evidence_read(opts = {})
@@ -233,7 +261,7 @@ module PWN
233
261
  root = File.join(File.dirname(LEARNING_FILE), 'learning', 'migrations')
234
262
  path = File.join(root, "#{id}.json")
235
263
  record = JSON.parse(evidence_read(path: path), symbolize_names: true)
236
- backup = evidence_read(path: File.join(root, "#{id}.backup.jsonl"))
264
+ backup = evidence_read(path: record[:backup] || File.join(root, "#{id}.backup.jsonl"))
237
265
  raise IOError, 'Invalid migration backup' unless record[:before_sha256] == id && Digest::SHA256.hexdigest(backup) == id
238
266
 
239
267
  current = evidence_read(path: LEARNING_FILE)
@@ -253,6 +281,8 @@ module PWN
253
281
 
254
282
  raise IOError, 'Restore source sessions before rollback' unless evidence_complete?(row: row) && File.file?(File.join(PWN::Sessions::SESSIONS_DIR, "#{sid}.jsonl"))
255
283
  end
284
+ return { dry_run: true, restored: false } if opts[:dry_run] == true
285
+
256
286
  evidence_write(path: LEARNING_FILE, body: backup)
257
287
  evidence_write(path: path, body: JSON.generate(record.merge(state: 'rolled_back')))
258
288
  { restored: true }
@@ -290,11 +320,12 @@ module PWN
290
320
  end
291
321
 
292
322
  private_class_method def self.evidence_commit(opts = {})
323
+ return PWN::Backups.transaction { evidence_commit(opts) } unless PWN::Backups.active?
324
+
293
325
  rows, original, snapshots, report = opts.values_at(:rows, :original, :snapshots, :report)
294
326
  root = File.join(File.dirname(LEARNING_FILE), 'learning')
295
327
  before_hash = Digest::SHA256.hexdigest(original)
296
- backup = File.join(root, 'migrations', "#{before_hash}.backup.jsonl")
297
- evidence_write(path: backup, body: original) unless File.exist?(backup)
328
+ backup = PWN::Backups.capture(path: LEARNING_FILE, label: 'learning-migration')
298
329
  raise IOError, 'Invalid migration backup' unless evidence_read(path: backup) == original
299
330
 
300
331
  rows.each do |row|
@@ -310,7 +341,7 @@ module PWN
310
341
  end
311
342
  updated = rows.map { |row| "#{JSON.generate(row)}\n" }.join
312
343
  ledger = File.join(root, 'migrations', "#{before_hash}.json")
313
- record = { version: 1, state: 'prepared', before_sha256: before_hash,
344
+ record = { version: 1, state: 'prepared', backup: backup, before_sha256: before_hash,
314
345
  after_sha256: Digest::SHA256.hexdigest(updated), migrated_rows: report[:migrated_rows] }
315
346
  evidence_write(path: ledger, body: JSON.generate(record))
316
347
  raise IOError, 'Learning changed during migration' unless evidence_read(path: LEARNING_FILE) == original
@@ -332,10 +363,11 @@ module PWN
332
363
  end
333
364
 
334
365
  # Reentrant across note_outcome -> pruning; flock serializes cooperating processes.
335
- private_class_method def self.learning_lock
366
+ private_class_method def self.learning_lock(opts = {})
336
367
  key = :pwn_learning_write_lock
337
368
  owner = Thread.current[key]
338
369
  return nil if owner.is_a?(Hash) && owner[:pid] == Process.pid && owner[:thread] == Thread.current && !owner[:file].closed?
370
+ raise IOError, 'Acquire the learning writer lock before the backup transaction' if PWN::Backups.active?
339
371
 
340
372
  root = File.dirname(LEARNING_FILE)
341
373
  FileUtils.mkdir_p(root, mode: 0o700)
@@ -344,7 +376,10 @@ module PWN
344
376
 
345
377
  # Ownership transfers to the caller's ensure/learning_unlock.
346
378
  file = File.open(path, File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) # rubocop:disable Style/FileOpen
347
- file.flock(File::LOCK_EX)
379
+ unless file.flock(File::LOCK_EX | (opts[:nonblocking] ? File::LOCK_NB : 0))
380
+ file.close
381
+ return :busy
382
+ end
348
383
  Thread.current[key] = { pid: Process.pid, thread: Thread.current, file: file }
349
384
  file
350
385
  end
@@ -2199,12 +2234,13 @@ module PWN
2199
2234
  end
2200
2235
 
2201
2236
  private_class_method def self.atomic_jsonl_write(opts = {})
2237
+ return PWN::Backups.transaction { atomic_jsonl_write(opts) } unless PWN::Backups.active?
2238
+
2202
2239
  path = opts[:path]
2203
2240
  rows = Array(opts[:rows])
2204
2241
  dir = File.dirname(path)
2205
2242
  FileUtils.mkdir_p(dir)
2206
- bak = "#{path}.bak-lean-#{Time.now.utc.strftime('%Y%m%d')}"
2207
- FileUtils.cp(path, bak) if File.exist?(path) && !File.exist?(bak)
2243
+ PWN::Backups.capture(path: path, label: 'learning') if File.exist?(path)
2208
2244
  tmp = File.join(dir, ".#{File.basename(path)}.#{Process.pid}.tmp")
2209
2245
  File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC, 0o644) do |f|
2210
2246
  f.flock(File::LOCK_EX)
@@ -2365,16 +2401,9 @@ module PWN
2365
2401
  end
2366
2402
 
2367
2403
  public_class_method def self.compact!(opts = {})
2368
- max_baks = (opts[:max_baks] || learning_max_baks).to_i
2369
- max_baks = 5 if max_baks <= 0
2370
- dir = File.dirname(LEARNING_FILE)
2371
- baks = Dir[File.join(dir, '*.bak*')].sort_by { |p| File.mtime(p) }.reverse
2372
- pruned = 0
2373
- baks.drop(max_baks).each do |path|
2374
- File.delete(path)
2375
- pruned += 1
2376
- end
2377
- { pruned: pruned, kept: [baks.length, max_baks].min, max_baks: max_baks }
2404
+ # Compatibility option cannot override the shared total retention cap.
2405
+ opts[:max_baks]
2406
+ { pruned: 0, max_baks: PWN::Backups::LIMIT, scope: 'global', legacy: 'explicit migration required' }
2378
2407
  end
2379
2408
 
2380
2409
  # Author(s):: 0day Inc. <support@0dayinc.com>
@@ -2388,10 +2417,18 @@ module PWN
2388
2417
  public_class_method def self.help
2389
2418
  puts "
2390
2419
  # Plan a full-snapshot evidence migration, or explicitly apply it.
2391
- #{self}.migrate_evidence(dry_run: 'optional - Boolean; defaults true, false writes evidence and references')
2420
+ #{self}.migrate_evidence(
2421
+ dry_run: 'optional - Boolean; defaults true, false writes evidence and references',
2422
+ max_sessions: 'optional - Positive maximum source snapshots per transaction',
2423
+ nonblocking: 'optional - Boolean; defer instead of waiting for a cooperating learning writer',
2424
+ stop: 'optional - Cooperative stop callable checked between source snapshots'
2425
+ )
2392
2426
 
2393
2427
  # Restore an unchanged learning ledger before any source-session cleanup.
2394
- #{self}.rollback_evidence(migration: 'required - SHA-256 migration ID from the ledger filename')
2428
+ #{self}.rollback_evidence(
2429
+ migration: 'required - SHA-256 migration ID from the ledger filename',
2430
+ dry_run: 'optional - validate rollback without replacing learning bytes; defaults to false'
2431
+ )
2395
2432
 
2396
2433
  # Persist derived PRM rewards without rewriting immutable supporting content.
2397
2434
  #{self}.annotate_evidence(
@@ -2438,9 +2475,9 @@ module PWN
2438
2475
  dry_run: 'optional - true to count without writing'
2439
2476
  )
2440
2477
 
2441
- # Prune excess *.bak siblings under the learning directory.
2478
+ # Report the shared global seven-generation retention policy.
2442
2479
  #{self}.compact!(
2443
- max_baks: 'optional - newest bak files to keep (defaults to 5)'
2480
+ max_baks: 'optional - legacy compatibility value; cannot override global seven-folder limit'
2444
2481
  )
2445
2482
 
2446
2483
  # Export an inert regression contract from a persisted verified failure.
@@ -990,6 +990,15 @@ module PWN
990
990
  text.sub(%r{\s*\[/UNTRUSTED TOOL OUTPUT\]\s*\z}m, '')
991
991
  end
992
992
 
993
+ private_class_method def self.terminal_effect_blocker(opts = {})
994
+ runtime = opts[:runtime]
995
+ diagnostic = runtime&.blocker
996
+ return nil unless diagnostic
997
+
998
+ runtime.finish!(status: 'blocked', unmet: runtime.unmet + [diagnostic])
999
+ "[pwn-ai] blocked: #{diagnostic}"
1000
+ end
1001
+
993
1002
  private_class_method def self.declared_deliverables(opts = {})
994
1003
  Array(declared_contract(request: opts[:request])[:paths])
995
1004
  end
@@ -1004,7 +1013,7 @@ module PWN
1004
1013
  end
1005
1014
  # Paths are user-owned. Inference cannot invent a destination or
1006
1015
  # convert the source document into a write obligation.
1007
- contract = contract.merge(paths: literals.any? ? literals : contract[:paths])
1016
+ contract = contract.merge(paths: literals)
1008
1017
  Thread.current[:pwn_loop_deliverables] = contract if Thread.current[:pwn_loop_active]
1009
1018
  contract
1010
1019
  end
@@ -2091,6 +2100,10 @@ module PWN
2091
2100
  model = opts[:model] || Thread.current[:pwn_swarm_model] || config[:model]
2092
2101
  cwt_opts[:model] = model unless model.to_s.strip.empty?
2093
2102
  cwt_opts[:temp] = opts[:temp] unless opts[:temp].nil?
2103
+ if PWN::AI::ServerTools.mixable?(engine: engine, model: cwt_opts[:model])
2104
+ hosted = Array(config[:server_tools]).map(&:to_s).reject(&:empty?)
2105
+ cwt_opts[:server_tools] = hosted if hosted.any?
2106
+ end
2094
2107
  if tools && !tools.empty?
2095
2108
  env_tc = begin
2096
2109
  config[:tool_choice]
@@ -3637,6 +3650,9 @@ module PWN
3637
3650
  return "[pwn-ai] #{status}: explicit request verification did not pass."
3638
3651
  end
3639
3652
  end
3653
+ effect_blocker = terminal_effect_blocker(runtime: runtime)
3654
+ return effect_blocker if effect_blocker
3655
+
3640
3656
  # Completion is an evidence decision, never a prose classifier.
3641
3657
  unless may_finalize?(
3642
3658
  request: request,
@@ -0,0 +1,331 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+ require 'fiddle'
5
+ require 'json'
6
+ require 'date'
7
+ require 'rbconfig'
8
+ require 'timeout'
9
+
10
+ # Load root autoloads before opening nested namespaces on direct require.
11
+ require 'pwn'
12
+
13
+ module PWN
14
+ module AI
15
+ module Agent
16
+ # Exec-based user namespace bootstrap; the terminal never enters a namespace.
17
+ # Only typed descriptors cross the private socket. Ruby closures are permitted
18
+ # below an existing owner, where fork does not need a new user namespace.
19
+ class MissionNamespace # rubocop:disable Metrics/ClassLength -- one exec handshake and process ownership protocol
20
+ class Unavailable < StandardError; end
21
+ CONTEXT_KEYS = %i[pwn_swarm_engine pwn_swarm_model pwn_swarm_id pwn_swarm_depth pwn_solve_workspace pwn_solve_tools
22
+ pwn_session_id pwn_artifact_contract pwn_active_steering_request pwn_turn_finalizer_depth
23
+ pwn_reflect_depth pwn_request_intent pwn_loop_budget_s pwn_loop_mutations pwn_loop_tokens].freeze
24
+ attr_reader :status
25
+
26
+ class << self
27
+ attr_accessor :owned, :bootstrap_requires, :force_exec
28
+
29
+ def prepared?
30
+ rows = File.readlines('/proc/self/uid_map').map { |line| line.split.map(&:to_i) }
31
+ count = rows.length == 1 ? rows.first.last : nil
32
+ # The initial host map is the full 32-bit range. Any smaller single
33
+ # line is a user namespace, including a root test bootstrap that must
34
+ # still include a non-root account.
35
+ count.is_a?(Integer) && count.positive? && count < 4_294_967_295
36
+ rescue SystemCallError
37
+ false
38
+ end
39
+
40
+ def io(number)
41
+ stream = Thread.current[:pwn_mission_fds]&.fetch(number, nil) || IO.for_fd(number)
42
+ stream.close_on_exec = true if number > 2
43
+ stream
44
+ end
45
+
46
+ def native(name, arguments)
47
+ Fiddle::Function.new(Fiddle::Handle::DEFAULT[name], arguments, Fiddle::TYPE_INT)
48
+ end
49
+
50
+ def checked(result, operation)
51
+ raise Unavailable, "#{operation}: #{SystemCallError.new(operation, Fiddle.last_error).message}; install util-linux unshare and enable Linux user/PID namespaces and pidfds" if result.negative?
52
+
53
+ result
54
+ end
55
+
56
+ # Tagged JSON preserves Symbol hash keys and values without deserializing
57
+ # executable Ruby objects or placing credentials in argv or temporary files.
58
+ def encode(value, depth = 0)
59
+ raise ArgumentError, 'mission snapshot is too deep or cyclic' if depth > 30
60
+
61
+ case value
62
+ when Hash then ['hash', value.map { |key, item| [encode(key, depth + 1), encode(item, depth + 1)] }]
63
+ when Array then ['array', value.map { |item| encode(item, depth + 1) }]
64
+ when Time then ['time', value.to_i, value.nsec, value.utc_offset, value.utc?]
65
+ when DateTime then ['datetime', value.iso8601(9)]
66
+ when Date then ['date', value.iso8601]
67
+ when Symbol then ['symbol', value.to_s]
68
+ when String then ['string', [value.b].pack('m0'), value.encoding.name]
69
+ when NilClass, TrueClass, FalseClass, Numeric then ['scalar', value]
70
+ else raise ArgumentError, "mission snapshot cannot transfer #{value.class}; use a serializable descriptor"
71
+ end
72
+ end
73
+
74
+ def decode(value)
75
+ type, body, encoding = value
76
+ case type
77
+ when 'hash' then body.to_h { |key, item| [decode(key), decode(item)] }
78
+ when 'array' then body.map { |item| decode(item) }
79
+ when 'time' then value[4] ? Time.at(body, encoding, :nanosecond).utc : Time.at(body, encoding, :nanosecond).getlocal(value[3])
80
+ when 'datetime' then DateTime.iso8601(body)
81
+ when 'date' then Date.iso8601(body)
82
+ when 'symbol' then body.to_sym
83
+ when 'string' then body.unpack1('m0').force_encoding(encoding)
84
+ when 'scalar' then body
85
+ else raise ArgumentError, 'invalid mission snapshot'
86
+ end
87
+ end
88
+
89
+ def supervise
90
+ channel = UNIXSocket.for_fd(3)
91
+ snapshot = decode(JSON.parse(channel.gets))
92
+ checked(native('unshare', [Fiddle::TYPE_INT]).call(0x20000000), 'unshare PID namespace')
93
+ reader, writer = IO.pipe
94
+ armed, arm = IO.pipe
95
+ init = fork do
96
+ armed.close
97
+ checked(native('prctl', [Fiddle::TYPE_INT, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG]).call(1, Signal.list.fetch('KILL'), 0, 0, 0), 'PR_SET_PDEATHSIG')
98
+ arm.write('1')
99
+ arm.close
100
+ channel.close
101
+ writer.close
102
+ exit! unless reader.read(1) == '1'
103
+
104
+ reader.close
105
+ self.owned = true
106
+ self.bootstrap_requires = snapshot.fetch(:requires, [])
107
+ PWN::Env.replace(decode(encode(snapshot.fetch(:env))))
108
+ bootstrap_requires.each { |path| require path }
109
+ PWN::Env.replace(snapshot.fetch(:env))
110
+ PWN.const_set(:Skills, snapshot[:skills]) if snapshot[:skills] && !PWN.const_defined?(:Skills)
111
+ snapshot.fetch(:locals).each { |key, value| Thread.current[key] = value }
112
+ begin
113
+ Thread.handle_interrupt(Exception => :immediate) { execute(snapshot.fetch(:descriptor)) }
114
+ completed = true
115
+ ensure
116
+ exit!(completed ? 0 : 1)
117
+ end
118
+ end
119
+ arm.close
120
+ raise Unavailable, 'namespace init exited before arming parent-death protection' unless armed.read(1) == '1'
121
+
122
+ armed.close
123
+ reader.close
124
+ fd = IO.new(checked(native('pidfd_open', [Fiddle::TYPE_INT, Fiddle::TYPE_INT]).call(init, 0), 'pidfd_open'))
125
+ channel.send_io(fd)
126
+ exit! unless channel.read(1) == '1'
127
+
128
+ writer.write('1')
129
+ writer.close
130
+ readable = IO.select([channel, fd]).first
131
+ native('pidfd_send_signal', [Fiddle::TYPE_INT, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT]).call(fd.fileno, Signal.list.fetch('KILL'), 0, 0) if readable.include?(channel)
132
+ _pid, status = Process.wait2(init)
133
+ init = nil
134
+ fd.close
135
+ status.exitstatus || (128 + status.termsig)
136
+ rescue Exception => e # rubocop:disable Lint/RescueException -- startup must fail closed
137
+ warn "Mission namespace unavailable: #{e.class}: #{e.message}"
138
+ ensure
139
+ if init
140
+ begin
141
+ Process.kill('KILL', init)
142
+ rescue Errno::ESRCH
143
+ nil
144
+ end
145
+ begin
146
+ Process.wait(init)
147
+ rescue Errno::ECHILD
148
+ nil
149
+ end
150
+ end
151
+ channel&.close unless channel&.closed?
152
+ end
153
+
154
+ def execute(descriptor)
155
+ case descriptor.fetch(:operation).to_sym
156
+ when :command
157
+ _pid, status = Process.wait2(Process.spawn(*descriptor.fetch(:argv)))
158
+ exit!(status.exitstatus || (128 + status.termsig))
159
+ when :loop
160
+ require 'pwn/ai/cli'
161
+ PWN::AI::CLI.send(:mission_entry, descriptor)
162
+ when :protected
163
+ PWN::Plugins::AISandbox.send(:mission_entry, descriptor)
164
+ when :console, :slash
165
+ PWN::Plugins::REPL::AIConsole::Console.send(:mission_entry, descriptor: descriptor)
166
+ when :legacy, :steerable
167
+ PWN::Plugins::REPL.pwn_ai_mission_entry(descriptor)
168
+ when :swarm
169
+ PWN::Plugins::REPL::AISwarm::Controller.send(:mission_entry, descriptor: descriptor)
170
+ else
171
+ raise ArgumentError, 'unknown mission operation'
172
+ end
173
+ end
174
+ end
175
+
176
+ def initialize(opts = {}, &operation)
177
+ @mutex = Mutex.new
178
+ raise Unavailable, 'mission containment requires Linux' unless RUBY_PLATFORM.include?('linux')
179
+
180
+ if self.class.prepared? && !self.class.force_exec && !(opts[:inherit_namespace] && self.class.owned)
181
+ start_prepared(opts, &operation)
182
+ return
183
+ end
184
+
185
+ if operation
186
+ raise Unavailable, 'host closures cannot cross an exec boundary; use a mission descriptor or explicitly bootstrap under unshare --user --map-current-user --keep-caps' unless self.class.owned
187
+
188
+ @pid = fork do
189
+ Thread.handle_interrupt(Exception => :immediate) { operation.call }
190
+ ensure
191
+ exit!
192
+ end
193
+ @pidfd = IO.new(self.class.checked(self.class.native('pidfd_open', [Fiddle::TYPE_INT, Fiddle::TYPE_INT]).call(@pid, 0), 'pidfd_open'))
194
+ return
195
+ end
196
+ snapshot = { requires: Array(self.class.bootstrap_requires), descriptor: opts.fetch(:descriptor), env: PWN::Env, locals: CONTEXT_KEYS.to_h { |key| [key, Thread.current[key]] } }
197
+ if PWN.const_defined?(:Skills)
198
+ snapshot[:skills] = PWN::Skills
199
+ ruby_skills = PWN::Skills.values.select { |skill| skill[:loaded] && skill[:type] == :ruby }
200
+ snapshot[:requires] += ruby_skills.flat_map do |skill|
201
+ skill[:format] == :legacy ? [skill.fetch(:path)] : Dir.glob(File.join(skill.fetch(:dir), 'scripts', '*.rb'))
202
+ end
203
+ snapshot[:requires].uniq!
204
+ end
205
+ payload = JSON.generate(self.class.encode(snapshot))
206
+ @channel, child = UNIXSocket.pair
207
+ mapping = opts.fetch(:fds, {}).merge(3 => child)
208
+ @pid = Process.spawn('unshare', '--user', '--map-current-user', '--keep-caps', RbConfig.ruby, '-I', $LOAD_PATH.join(File::PATH_SEPARATOR), '-rpwn', '-e', 'exit!(PWN::AI::Agent::MissionNamespace.supervise || 1)', mapping.merge(close_others: true))
209
+ child.close
210
+ Timeout.timeout(15) do
211
+ @channel.puts(payload)
212
+ @pidfd = @channel.recv_io
213
+ @channel.write('1')
214
+ end
215
+ rescue Exception => e # rubocop:disable Lint/RescueException -- failed startup cannot leak a child
216
+ close
217
+ raise Unavailable, "mission not started: #{e.message}; install util-linux unshare and enable Linux user/PID namespaces and pidfds"
218
+ ensure
219
+ child&.close unless child&.closed?
220
+ end
221
+
222
+ def wait
223
+ @pidfd.wait_readable if @pidfd && !@pidfd.closed?
224
+ @mutex.synchronize { reap }
225
+ rescue IOError, Errno::EBADF
226
+ @mutex.synchronize { reap }
227
+ end
228
+
229
+ def close
230
+ return :reaped unless @mutex
231
+
232
+ @mutex.synchronize do
233
+ if @pidfd && !@pidfd.closed?
234
+ result = self.class.native('pidfd_send_signal', [Fiddle::TYPE_INT, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT]).call(@pidfd.fileno, Signal.list.fetch('KILL'), 0, 0)
235
+ self.class.checked(result, 'pidfd_send_signal') unless result.negative? && Fiddle.last_error == Errno::ESRCH::Errno
236
+ elsif @pid
237
+ begin
238
+ Process.kill('KILL', @pid)
239
+ rescue Errno::ESRCH
240
+ nil
241
+ end
242
+ end
243
+ reap
244
+ end
245
+ end
246
+
247
+ public_class_method def self.authors
248
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
249
+ end
250
+
251
+ public_class_method def self.help
252
+ puts <<~HELP
253
+ # Identify module authors.
254
+ #{self}.authors
255
+
256
+ # Launch a private Linux mission and retain its cancellation owner.
257
+ #{self}.new(
258
+ descriptor: 'required - serializable operation and request parameters',
259
+ fds: 'optional - explicitly inherited descriptor map'
260
+ )
261
+ HELP
262
+ end
263
+
264
+ private
265
+
266
+ def start_prepared(opts, &operation)
267
+ ready, announce = IO.pipe
268
+ release, gate = IO.pipe
269
+ owner = Process.pid
270
+ @pid = fork do
271
+ ready.close
272
+ gate.close
273
+ begin
274
+ self.class.checked(self.class.native('prctl', [Fiddle::TYPE_INT, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG]).call(1, Signal.list.fetch('KILL'), 0, 0, 0), 'PR_SET_PDEATHSIG')
275
+ exit! unless Process.ppid == owner
276
+ self.class.checked(self.class.native('unshare', [Fiddle::TYPE_INT]).call(0x20000000), 'unshare PID namespace')
277
+ armed, arm = IO.pipe
278
+ init = fork do
279
+ armed.close
280
+ announce.close
281
+ self.class.checked(self.class.native('prctl', [Fiddle::TYPE_INT, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG]).call(1, Signal.list.fetch('KILL'), 0, 0, 0), 'PR_SET_PDEATHSIG')
282
+ arm.write('1')
283
+ arm.close
284
+ exit! unless release.read(1) == '1'
285
+ release.close
286
+ self.class.owned = true
287
+ Thread.current[:pwn_mission_fds] = opts.fetch(:fds, {})
288
+ begin
289
+ Thread.handle_interrupt(Exception => :immediate) { operation ? operation.call : self.class.execute(opts.fetch(:descriptor)) }
290
+ completed = true
291
+ ensure
292
+ exit!(completed ? 0 : 1)
293
+ end
294
+ end
295
+ arm.close
296
+ raise Unavailable, 'namespace init exited before arming parent-death protection' unless armed.read(1) == '1'
297
+
298
+ armed.close
299
+ announce.puts(JSON.generate(pid: init))
300
+ announce.close
301
+ release.close
302
+ _pid, status = Process.wait2(init)
303
+ exit!(status.exitstatus || (128 + status.termsig))
304
+ rescue Exception => e # rubocop:disable Lint/RescueException -- report child startup errors
305
+ announce.puts(JSON.generate(error: e.message)) unless announce.closed?
306
+ ensure
307
+ exit!
308
+ end
309
+ end
310
+ announce.close
311
+ release.close
312
+ message = JSON.parse(ready.gets || '{}')
313
+ raise Unavailable, message.fetch('error', 'namespace supervisor exited') unless message['pid']
314
+
315
+ @pidfd = IO.new(self.class.checked(self.class.native('pidfd_open', [Fiddle::TYPE_INT, Fiddle::TYPE_INT]).call(message.fetch('pid'), 0), 'pidfd_open'))
316
+ gate.write('1')
317
+ ensure
318
+ [ready, announce, release, gate].compact.each { |io| io.close unless io.closed? }
319
+ end
320
+
321
+ def reap
322
+ _pid, @status = Process.wait2(@pid) if @pid
323
+ @pid = nil
324
+ @channel&.close unless @channel&.closed?
325
+ @pidfd&.close unless @pidfd&.closed?
326
+ :reaped
327
+ end
328
+ end
329
+ end
330
+ end
331
+ end
@@ -4,6 +4,7 @@ require 'json'
4
4
  require 'fileutils'
5
5
  require 'time'
6
6
  require 'digest'
7
+ require_relative '../../memory'
7
8
 
8
9
  module PWN
9
10
  module AI
@@ -696,6 +697,8 @@ module PWN
696
697
 
697
698
  public_class_method def self.lean!(opts = {})
698
699
  dry = opts[:dry_run] ? true : false
700
+ return PWN::Memory.transaction(path: TRAJECTORY_FILE) { lean!(opts) } unless dry || PWN::Memory.transaction_owned?(path: TRAJECTORY_FILE)
701
+
699
702
  return { skipped: true } unless File.exist?(TRAJECTORY_FILE)
700
703
 
701
704
  rows = File.readlines(TRAJECTORY_FILE)
@@ -1385,6 +1388,8 @@ module PWN
1385
1388
  end
1386
1389
 
1387
1390
  private_class_method def self.persist_episode!(opts = {})
1391
+ return PWN::Memory.transaction(path: TRAJECTORY_FILE) { persist_episode!(opts) } unless PWN::Memory.transaction_owned?(path: TRAJECTORY_FILE)
1392
+
1388
1393
  ep = opts[:episode]
1389
1394
  return unless ep.is_a?(Hash)
1390
1395
 
@@ -59,6 +59,8 @@ module PWN
59
59
  # Explicit operator action only; never called from Policy.finish/Loop.
60
60
  public_class_method def self.promote(opts = {})
61
61
  return { promoted: false, reason: 'disabled' } unless opts[:enabled] == true
62
+ return PWN::Backups.transaction { promote(opts) } unless PWN::Backups.active?
63
+
62
64
  raise ArgumentError, 'stop all policy writers and set quiescent: true' unless opts[:quiescent] == true
63
65
 
64
66
  baseline = read_snapshot(path: opts.fetch(:baseline))
@@ -80,12 +82,7 @@ module PWN
80
82
  raise ArgumentError, 'snapshot changed during evaluation' unless replayed.all? { |r| r[:snapshot_sha256] == { baseline: prior_digest, candidate: candidate_digest } }
81
83
  raise ArgumentError, 'live policy is not the evaluated baseline' unless read_snapshot(path: live) == baseline
82
84
 
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
85
+ backup = PWN::Backups.capture(path: live, label: 'policy-evaluation', pin: true)
89
86
  replace(path: live, bytes: candidate)
90
87
  { promoted: true, receipt_version: 1, change_kind: 'routing_policy',
91
88
  acceptance_sha256: Digest::SHA256.hexdigest(JSON.generate(deterministic(value: replayed))),
@@ -97,6 +94,8 @@ module PWN
97
94
 
98
95
  public_class_method def self.rollback(opts = {})
99
96
  return { rolled_back: false, reason: 'disabled' } unless opts[:enabled] == true
97
+ return PWN::Backups.transaction { rollback(opts) } unless PWN::Backups.active?
98
+
100
99
  raise ArgumentError, 'stop all policy writers and set quiescent: true' unless opts[:quiescent] == true
101
100
 
102
101
  receipt = opts.fetch(:receipt)
@@ -106,8 +105,9 @@ module PWN
106
105
  prior_digest = receipt.fetch(:baseline_sha256)
107
106
  raise ArgumentError, 'invalid rollback digest' unless prior_digest.is_a?(String) && prior_digest.match?(/\A[0-9a-f]{64}\z/)
108
107
 
109
- backup = "#{live}.rollback-#{prior_digest}.json"
110
- raise ArgumentError, 'rollback target mismatch' unless receipt[:live_path] == live && receipt[:rollback_path] == backup
108
+ backup = File.expand_path(receipt.fetch(:rollback_path))
109
+ root = Thread.current[:pwn_backup_transaction][:root]
110
+ raise ArgumentError, 'rollback target mismatch' unless receipt[:live_path] == live && backup.start_with?("#{root}/") && File.realpath(backup) == backup
111
111
 
112
112
  bytes = read_snapshot(path: backup)
113
113
  raise ArgumentError, 'rollback snapshot digest mismatch' unless Digest::SHA256.hexdigest(bytes) == prior_digest