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
@@ -3,6 +3,337 @@
3
3
  require 'spec_helper'
4
4
  require File.expand_path('../../../etc/default_cron/session_cleanup', __dir__)
5
5
 
6
+ RSpec.describe PWN::SessionCleanup do
7
+ include_context 'pwn tmp sandbox'
8
+
9
+ let(:id) { '20200101_010203_abcdef12' }
10
+ let(:title) { "pwn-ai session #{id}" }
11
+ let(:path) { File.join(PWN::Sessions.sessions_dir, "#{id}.jsonl") }
12
+ let(:meta_path) { path.sub('.jsonl', '.meta.json') }
13
+
14
+ before do
15
+ allow(Dir).to receive(:home).and_return(File.join(@tmp, 'home'))
16
+ FileUtils.mkdir_p(File.join(Dir.home, '.pwn'))
17
+ stub_const('PWN::AI::Agent::Learning::LEARNING_FILE', File.join(Dir.home, '.pwn', 'learning.jsonl'))
18
+ PWN::Sessions.create(id: id)
19
+ end
20
+
21
+ def metadata(value)
22
+ File.write(meta_path, JSON.generate({ id: id, title: title, source: 'pwn-ai' }.merge(value)))
23
+ end
24
+
25
+ describe 'housekeeping inventory' do
26
+ it 'keeps filesystem metadata and bytes unchanged during status and maintenance dry-run' do
27
+ File.unlink(PWN::Sessions.support_path(kind: :lease, session_id: id))
28
+ snapshot = lambda do
29
+ Dir.glob(File.join(@tmp, '**', '*'), File::FNM_DOTMATCH).sort.to_h do |entry|
30
+ stat = File.lstat(entry)
31
+ [entry, [stat.ino, stat.mode, stat.size, stat.mtime, stat.ctime, stat.file? ? Digest::SHA256.file(entry).hexdigest : nil]]
32
+ end
33
+ end
34
+ before = snapshot.call
35
+ described_class.status
36
+ result = described_class.maintain(dry_run: true)
37
+ expect(result[:dry_run]).to be(true)
38
+ expect(snapshot.call).to eq(before)
39
+ end
40
+
41
+ it 'includes legacy migration backups as protected bounded inventory' do
42
+ directory = File.join(Dir.home, '.pwn', 'backup', '20261025_120000')
43
+ FileUtils.mkdir_p(directory)
44
+ File.write(File.join(directory, 'custom.json'), 'only-copy')
45
+ before = File.stat(directory)
46
+ row = described_class.status[:categories].fetch('backups')
47
+ expect(row).to include(files: 1, bytes: 9, unique_bytes: 9, incomplete: false,
48
+ policy_mode: 'protected_evidence_no_automatic_deletion')
49
+ expect(row[:retention_days]).to be_nil
50
+ expect(File.stat(directory).mtime).to eq(before.mtime)
51
+ expect(described_class.prune_legacy_backups[:compacted]).to be_empty
52
+ end
53
+
54
+ it 'evicts real producer cache under budget pressure but not an active reader or unknown bytes' do
55
+ described_class.install
56
+ config = described_class.policy
57
+ config['budget_bytes']['cache'] = 0
58
+ File.write(File.join(Dir.home, '.pwn', 'maintenance', 'policy.json'), JSON.generate(config))
59
+ binary = File.join(@tmp, 'binary')
60
+ File.binwrite(binary, "binary\xFF".b)
61
+ allow(PWN::Plugins::Ghidra).to receive(:analyze).and_return(stdout: '分析', exit: 0)
62
+ expect(PWN::Plugins::Ghidra.decompile(bin: binary)[:stdout]).to eq('分析')
63
+ expect(PWN::Plugins::Ghidra.decompile(bin: binary)[:cached]).to be(true)
64
+ cache = File.join(Dir.home, '.pwn', 'cache', 'decompile', "#{Digest::SHA256.file(binary).hexdigest}.json")
65
+ unknown = File.join(File.dirname(cache), 'unknown')
66
+ File.write(unknown, 'keep')
67
+ File.open("#{cache}.lease", 'r') do |lease|
68
+ lease.flock(File::LOCK_SH)
69
+ expect(described_class.clean_disposables(dry_run: false)[:deleted]).to be_empty
70
+ end
71
+ expect(described_class.clean_disposables(dry_run: false)[:deleted]).to eq([cache])
72
+ expect(File.read(unknown)).to eq('keep')
73
+ end
74
+
75
+ it 'keeps the old cache intact when atomic replacement runs out of space' do
76
+ cache = File.join(Dir.home, '.pwn', 'cache', 'failure')
77
+ described_class.cache(path: cache, bytes: 'before')
78
+ allow(File).to receive(:rename).and_call_original
79
+ allow(File).to receive(:rename).with(anything, cache).and_raise(Errno::ENOSPC)
80
+ expect { described_class.cache(path: cache, bytes: 'after') }.to raise_error(Errno::ENOSPC)
81
+ expect(described_class.cache(path: cache)).to eq('before')
82
+ expect(Dir.glob("#{cache}.*").reject { |name| name.end_with?('.lease') }).to be_empty
83
+ end
84
+
85
+ it 'rotates real ordinary logs and protects debug writers through request closure' do
86
+ described_class.install
87
+ log = File.join(Dir.home, '.pwn', 'logs', 'pwn.log')
88
+ PWN::Plugins::Log.append(level: :info, msg: 'first', which_self: self)
89
+ expect(File.read(log)).to include('first')
90
+ File.open(log, 'a') { |file| file.write('x' * 1_024_000) }
91
+ PWN::Plugins::Log.append(level: :info, msg: 'second', which_self: self)
92
+ expect(File.read(log)).to include('second')
93
+ expect(File.size(log)).to be < 1_024_000
94
+ expect(Dir.glob("#{log}.*").reject { |name| name.end_with?('.lease') }.length).to eq(1)
95
+ debug = File.join(Dir.home, '.pwn', 'logs', 'request.log')
96
+ PWN::Plugins::Log.send(:open_debug_file!, path: debug)
97
+ expect { described_class.register_disposable(path: debug, category: 'logs') }.to raise_error(IOError, /active/)
98
+ PWN::Plugins::Log.send(:close_debug_file!)
99
+ manifest = File.join(Dir.home, '.pwn', 'maintenance', 'disposable', "#{Digest::SHA256.hexdigest(debug)}.json")
100
+ expect(File.file?(manifest)).to be(true)
101
+ ensure
102
+ PWN::Plugins::Log.send(:close_debug_file!)
103
+ end
104
+
105
+ it 'coalesces startup work without scanning and executes it once through the existing cron tick' do
106
+ cron = File.join(Dir.home, '.pwn', 'cron')
107
+ allow(PWN::Cron).to receive(:cron_dir).and_return(cron)
108
+ FileUtils.mkdir_p(cron)
109
+ expect(described_class).not_to receive(:status)
110
+ expect(described_class).not_to receive(:maintain)
111
+ PWN::Cron.install_defaults
112
+ request = File.join(Dir.home, '.pwn', 'maintenance', 'background.json')
113
+ bytes = File.binread(request)
114
+ PWN::Cron.install_defaults
115
+ expect(File.binread(request)).to eq(bytes)
116
+ allow(PWN::Cron).to receive(:due_jobs).and_return({})
117
+ job = PWN::Cron.list.values.find { |entry| entry[:name] == 'session_cleanup' }
118
+ expect(PWN::Cron).to receive(:run).with(id: job[:id]).once.and_return(status: 'success', duration: 0)
119
+ expect(PWN::Cron.tick.length).to eq(1)
120
+ expect(PWN::Cron.tick).to be_empty
121
+ expect(JSON.parse(File.read(request))['state']).to eq('completed')
122
+ end
123
+
124
+ it 'reports an interrupted broad transaction without losing the prepared ledger' do
125
+ described_class.install
126
+ allow(described_class).to receive(:maintenance_transaction).and_raise(Interrupt)
127
+ expect { described_class.maintain(dry_run: false) }.to raise_error(Interrupt)
128
+ ledger = Dir.glob(File.join(Dir.home, '.pwn', 'maintenance', 'runs', '*.json')).fetch(0)
129
+ expect(JSON.parse(File.read(ledger))).to include('state' => 'interrupted', 'error' => 'Interrupt')
130
+ end
131
+
132
+ it 'serializes cache publication across real processes and threads' do
133
+ cache = File.join(Dir.home, '.pwn', 'cache', 'concurrent')
134
+ described_class.cache(path: cache, bytes: 'initial')
135
+ workers = 2.times.map do |worker|
136
+ fork do
137
+ threads = 3.times.map do |thread|
138
+ Thread.new { 8.times { |index| described_class.cache(path: cache, bytes: JSON.generate(worker: worker, thread: thread, index: index, text: '日本語')) } }
139
+ end
140
+ threads.each(&:value)
141
+ exit! 0
142
+ rescue StandardError
143
+ exit! 1
144
+ end
145
+ end
146
+ workers.each { |pid| expect(Process.wait2(pid).last.success?).to be(true) }
147
+ body = described_class.cache(path: cache)
148
+ expect(JSON.parse(body)['text']).to eq('日本語')
149
+ manifest = File.join(Dir.home, '.pwn', 'maintenance', 'disposable', "#{Digest::SHA256.hexdigest(cache)}.json")
150
+ expect(JSON.parse(File.read(manifest))['sha256']).to eq(Digest::SHA256.hexdigest(body))
151
+ end
152
+
153
+ it 'bounds evidence migration without dropping unmigrated rows or breaking readers' do
154
+ other = '20200101_010204_abcdef12'
155
+ PWN::Sessions.create(id: other)
156
+ [id, other].each { |sid| PWN::Sessions.append(session_id: sid, role: 'user', content: '証拠') }
157
+ file = PWN::AI::Agent::Learning::LEARNING_FILE
158
+ File.write(file, [id, other].map { |sid| JSON.generate(session_id: sid, outcome: 'fixture') }.join("\n"))
159
+ result = PWN::AI::Agent::Learning.migrate_evidence(dry_run: false, max_sessions: 1)
160
+ expect(result[:migrated_rows]).to eq(1)
161
+ rows = File.readlines(file).map { |line| JSON.parse(line) }
162
+ expect(rows.length).to eq(2)
163
+ expect(rows.count { |row| row['evidence_ref'] }).to eq(1)
164
+ expect(PWN::Sessions.load(session_id: other).last[:content]).to eq('証拠')
165
+ expect(PWN::AI::Agent::Learning.migrate_evidence(dry_run: false, max_sessions: 1)[:migrated_rows]).to eq(1)
166
+ end
167
+
168
+ it 'honors session retention floors and cooperative cancellation before deletion' do
169
+ allow(described_class).to receive(:legacy_owners).and_return([])
170
+ described_class.install
171
+ config = File.join(Dir.home, '.pwn', 'maintenance', 'policy.json')
172
+ value = described_class.policy
173
+ value['retention_days']['sessions'] = 1
174
+ File.write(config, JSON.generate(value))
175
+ expect(described_class.run(dry_run: false)[:deleted_ids]).to be_empty
176
+ value['retention_days']['sessions'] = 0
177
+ File.write(config, JSON.generate(value))
178
+ control = double(stopping?: true)
179
+ Thread.current[:pwn_steering_input] = control
180
+ expect(described_class.run(dry_run: false)[:deleted_ids]).to be_empty
181
+ expect(File.file?(path)).to be(true)
182
+ ensure
183
+ Thread.current[:pwn_steering_input] = nil
184
+ end
185
+
186
+ it 'leaves centralized recovery expiry to shared admission rather than quarantine pruning' do
187
+ file = File.join(described_class.disposable_directory('tmp'), 'expired-backup')
188
+ FileUtils.mkdir_p(File.dirname(file))
189
+ File.write(file, 'disposable')
190
+ described_class.register_disposable(path: file, category: 'tmp', expires_at: 0)
191
+ result = described_class.clean_disposables(dry_run: false)
192
+ backup = result[:records].first['backup']
193
+ old = Time.now - (100 * 86_400)
194
+ File.utime(old, old, result[:ledger])
195
+ expect(described_class.prune_quarantine(dry_run: true)[:eligible]).to eq([])
196
+ expect(described_class.prune_quarantine(dry_run: false)[:deleted]).to eq([])
197
+ expect(File.read(backup)).to eq('disposable')
198
+ expect(JSON.parse(File.read(result[:ledger]))['state']).to eq('committed')
199
+ end
200
+
201
+ it 'requires explicit continuation retirement and preserves an active association' do
202
+ swarm = PWN::AI::Agent::Swarm
203
+ root = File.join(Dir.home, '.pwn', 'swarm')
204
+ stub_const('PWN::AI::Agent::Swarm::SWARM_ROOT', root)
205
+ sid = swarm.create[:swarm_id]
206
+ swarm.spawn(name: 'fixture', role: 'fixture', swarm_id: sid)
207
+ map = File.join(root, sid, 'personas.json')
208
+ File.write(map, JSON.generate('fixture' => id))
209
+ lease = PWN::Sessions.activate(session_id: id)
210
+ expect { swarm.retire(name: 'fixture', swarm_id: sid, local: true, continuation: true) }.to raise_error(IOError, /active/)
211
+ expect(JSON.parse(File.read(map))['fixture']).to eq(id)
212
+ PWN::Sessions.deactivate(lease: lease)
213
+ lease = nil
214
+ result = swarm.retire(name: 'fixture', swarm_id: sid, local: true, continuation: true)
215
+ expect(result[:continuation]).to include(session_id: id, state: 'committed')
216
+ expect(JSON.parse(File.read(map))).not_to have_key('fixture')
217
+ expect(File.exist?(path)).to be(true)
218
+ expect(swarm.send(:persona_session, swarm_id: sid, name: 'fixture')).not_to eq(id)
219
+ ensure
220
+ PWN::Sessions.deactivate(lease: lease) if lease
221
+ end
222
+
223
+ it 'bounds applying session batches without hiding remaining candidates' do
224
+ allow(described_class).to receive(:legacy_owners).and_return([])
225
+ PWN::Sessions.create(id: '20200101_010203_abcdef13')
226
+ described_class.install
227
+ config = File.join(Dir.home, '.pwn', 'maintenance', 'policy.json')
228
+ File.write(config, JSON.generate(described_class.policy.merge('batch_size' => 1)))
229
+ result = described_class.run(dry_run: false)
230
+ expect(result[:deleted_ids].length).to eq(1)
231
+ expect(result[:candidate_ids].length).to eq(2)
232
+ expect(result[:skipped][:batch_limit]).to eq(1)
233
+ expect(Dir.glob(File.join(PWN::Sessions.sessions_dir, '*.jsonl')).length).to eq(1)
234
+ end
235
+
236
+ it 'preserves failed writes and exposes interrupted prepared records instead of declaring completion' do
237
+ file = File.join(described_class.disposable_directory('tmp'), 'failure')
238
+ FileUtils.mkdir_p(File.dirname(file))
239
+ File.binwrite(file, "binary\xFF".b)
240
+ described_class.register_disposable(path: file, category: 'tmp', expires_at: 0)
241
+ allow(File).to receive(:unlink).and_call_original
242
+ allow(File).to receive(:unlink).with(file).and_raise(Errno::EACCES)
243
+ result = described_class.clean_disposables(dry_run: false)
244
+ expect(result[:state]).to eq('interrupted')
245
+ expect(File.binread(file)).to eq("binary\xFF".b)
246
+ ledger = JSON.parse(File.binread(result[:ledger]))
247
+ expect(ledger['records'].first['state']).to eq('prepared')
248
+ expect(File.binread(ledger['records'].first['backup'])).to eq(File.binread(file))
249
+ ensure
250
+ allow(File).to receive(:unlink).and_call_original
251
+ end
252
+
253
+ it 'recovers exact disposable backups without overwriting changed destinations' do
254
+ file = File.join(described_class.disposable_directory('tmp'), 'binary')
255
+ FileUtils.mkdir_p(File.dirname(file))
256
+ bytes = "café\xFF\x00".b
257
+ File.binwrite(file, bytes)
258
+ described_class.register_disposable(path: file, category: 'tmp', expires_at: 0)
259
+ result = described_class.clean_disposables(dry_run: false)
260
+ expect(described_class.recover(ledger: result[:ledger])[:restored]).to be_empty
261
+ expect(File.exist?(file)).to be(false)
262
+ expect(described_class.recover(ledger: result[:ledger], dry_run: false)[:restored]).to eq([file])
263
+ expect(File.binread(file)).to eq(bytes)
264
+ File.binwrite(file, 'later user edit')
265
+ expect { described_class.recover(ledger: result[:ledger], dry_run: false) }.to raise_error(IOError, /existing/)
266
+ expect(File.binread(file)).to eq('later user edit')
267
+ end
268
+
269
+ it 'serializes transcript compaction and append on a permanent writer inode' do
270
+ lock_path = "#{path}.write.lock"
271
+ writer = nil
272
+ File.open(lock_path, 'a') do |lock|
273
+ lock.flock(File::LOCK_EX)
274
+ entered = Queue.new
275
+ writer = Thread.new do
276
+ entered << true
277
+ PWN::Sessions.append(session_id: id, role: 'assistant', content: 'retained append')
278
+ end
279
+ entered.pop
280
+ expect(writer.join(0.05)).to be_nil
281
+ end
282
+ writer.join(2)
283
+ expect(writer).not_to be_alive
284
+ expect(PWN::Sessions.load(session_id: id).last[:content]).to eq('retained append')
285
+ inode = File.stat(lock_path).ino
286
+ PWN::Sessions.send(:compact_transcript!, path: path, assistant_content_max: 2)
287
+ expect(File.stat(lock_path).ino).to eq(inode)
288
+ expect(Dir.glob("#{path}.*.tmp")).to be_empty
289
+ ensure
290
+ writer&.join(2)
291
+ end
292
+
293
+ it 'dispatches maintenance status locally and shares routine cleanup with the cron entry point' do
294
+ expect(PWN::Plugins::REPL).to receive(:pwn_ai_print_result).with(value: hash_including(operation: 'status'))
295
+ expect(PWN::Plugins::REPL.pwn_ai_dispatch_slash!(request: '/maintenance status', raise_errors: true)).to be_truthy
296
+ allow(described_class).to receive(:legacy_owners).and_return([])
297
+ expect(described_class).to receive(:clean_disposables).with(dry_run: true).and_call_original
298
+ expect(described_class.maintain(dry_run: true)[:storage]).to include(operation: 'routine')
299
+ end
300
+
301
+ it 'cleans only verified expired disposable bytes and preserves active, changed and unknown files' do
302
+ root = File.join(Dir.home, '.pwn', 'cache')
303
+ FileUtils.mkdir_p(root)
304
+ paths = %w[expired active changed unknown].to_h { |name| [name, File.join(root, name)] }
305
+ paths.each_value { |file| File.binwrite(file, "café\x00".b) }
306
+ %w[expired active changed].each { |name| described_class.register_disposable(path: paths[name], category: 'cache', expires_at: Time.now.to_i - 1) }
307
+ File.binwrite(paths['changed'], 'new data')
308
+ File.open("#{paths['active']}.lease", 'a') do |lease|
309
+ lease.flock(File::LOCK_SH)
310
+ preview = described_class.clean_disposables(dry_run: true)
311
+ expect(preview[:eligible]).to eq([paths['expired']])
312
+ expect(File.exist?(paths['expired'])).to be(true)
313
+ result = described_class.clean_disposables(dry_run: false)
314
+ expect(result[:deleted]).to eq([paths['expired']])
315
+ expect(File.exist?(paths['expired'])).to be(false)
316
+ expect(JSON.parse(File.binread(result[:ledger]))['state']).to eq('committed')
317
+ end
318
+ %w[active changed unknown].each { |name| expect(File.file?(paths[name])).to be(true) }
319
+ end
320
+
321
+ it 'reports all storage categories read-only and preserves unknown files' do
322
+ root = File.join(Dir.home, '.pwn')
323
+ runtime = described_class.disposable_directory('tmp')
324
+ FileUtils.mkdir_p(runtime)
325
+ unknown = File.join(runtime, 'unknown.bin')
326
+ File.binwrite(unknown, "\xFF\x00".b)
327
+ before = Dir.glob(File.join(root, '**', '*'), File::FNM_DOTMATCH).sort
328
+ status = described_class.status
329
+ expect(status[:categories].keys).to match_array(%w[sessions learning artifacts state logs cache tmp maintenance backups])
330
+ expect(status[:categories]['tmp']).to include(files: 1, bytes: 2)
331
+ expect(Dir.glob(File.join(root, '**', '*'), File::FNM_DOTMATCH).sort).to eq(before)
332
+ expect(File.binread(unknown)).to eq("\xFF\x00".b)
333
+ end
334
+ end
335
+ end
336
+
6
337
  RSpec.describe PWN::SessionCleanup do
7
338
  include_context 'pwn tmp sandbox'
8
339
 
@@ -289,14 +620,16 @@ RSpec.describe PWN::SessionCleanup do
289
620
  cron_id = PWN::Sessions.create[:id]
290
621
  payload = PWN::Cron::DEFAULT_JOBS.find { |job| job[:name] == 'session_cleanup' }[:ruby]
291
622
  allow(PWN::Cron).to receive(:cron_dir).and_return(File.expand_path('../../../etc/default_cron', __dir__))
292
- expect(eval(payload)[:deleted_ids]).to eq([cron_id]) # rubocop:disable Security/Eval -- execute the exact bundled local job
623
+ coordinated = eval(payload) # rubocop:disable Security/Eval -- execute the exact bundled local job
624
+ expect(coordinated.dig(:phases, :maintenance, :deleted_ids)).to eq([cron_id])
625
+ expect(File).not_to exist(File.join(PWN::Sessions.sessions_dir, "#{cron_id}.jsonl"))
293
626
  legacy_id = PWN::Sessions.create[:id]
294
627
  expect(described_class.run(dry_run: false)[:deleted_ids]).to eq([legacy_id])
295
628
  end
296
629
 
297
630
  it 'does not migrate evidence or delete while legacy ownership is unresolved' do
298
631
  allow(described_class).to receive(:legacy_owners).and_return([{ pid: 123, reason: 'legacy_session_ownership_unknown' }])
299
- expect(PWN::AI::Agent::Learning).to receive(:migrate_evidence).with(dry_run: true).and_call_original
632
+ expect(PWN::AI::Agent::Learning).to receive(:migrate_evidence).with(hash_including(dry_run: true, max_sessions: 100)).and_call_original
300
633
  expect(described_class.maintain[:apply_blocked]).to be(true)
301
634
  expect(File).to exist(path)
302
635
  end