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
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module PWN
6
+ module AI
7
+ # Provider-hosted tools, separate from PWN::AI::Agent::Registry.
8
+ #
9
+ # Open WebUI is the only engine here with a user-installed tool workspace.
10
+ # Ask Jev is one of those tools. The other engines either run a fixed set of
11
+ # hosted tools on their own API, or they have no server-side tool surface.
12
+ module ServerTools
13
+ OPENAI = {
14
+ 'web_search' => { type: 'web_search' },
15
+ 'code_interpreter' => { type: 'code_interpreter' }
16
+ }.freeze
17
+ ANTHROPIC = {
18
+ 'web_search' => { type: 'web_search_20250305', name: 'web_search' },
19
+ 'web_fetch' => { type: 'web_fetch_20250910', name: 'web_fetch' }
20
+ }.freeze
21
+ GEMINI = {
22
+ 'google_search' => { googleSearch: {} }
23
+ }.freeze
24
+ GROK = {
25
+ 'web_search' => { type: 'web_search' },
26
+ 'x_search' => { type: 'x_search' },
27
+ 'code_interpreter' => { type: 'code_interpreter' }
28
+ }.freeze
29
+ WIRE = { openai: OPENAI, anthropic: ANTHROPIC, gemini: GEMINI, grok: GROK }.freeze
30
+
31
+ # Supported Method Parameters::
32
+ # row = PWN::AI::ServerTools.catalog(
33
+ # engine: 'required - openai, anthropic, gemini, grok, ollama, or openwebui'
34
+ # )
35
+ public_class_method def self.catalog(opts = {})
36
+ engine = opts[:engine].to_s.downcase.to_sym
37
+ {
38
+ engine: engine,
39
+ workspace: engine == :openwebui,
40
+ mixable: mixable?(engine: engine, model: opts[:model]),
41
+ tools: engine == :openwebui ? [] : Array(WIRE[engine]).map { |name, _wire| name }
42
+ }
43
+ end
44
+
45
+ # Supported Method Parameters::
46
+ # rows = PWN::AI::ServerTools.list(
47
+ # engine: 'required - provider name',
48
+ # refresh: 'optional - true re-reads the Open WebUI workspace tool list',
49
+ # model: 'optional - used to decide whether OpenAI hosted tools share the request'
50
+ # )
51
+ public_class_method def self.list(opts = {})
52
+ engine = opts[:engine].to_s.downcase.to_sym
53
+ return PWN::AI::OpenWebUI.workspace_tools(refresh: opts[:refresh]) if engine == :openwebui
54
+
55
+ catalog(engine: engine, model: opts[:model])[:tools]
56
+ end
57
+
58
+ # Supported Method Parameters::
59
+ # objects = PWN::AI::ServerTools.wire(
60
+ # engine: 'required - provider name',
61
+ # names: 'required - hosted tool names such as web_search or google_search'
62
+ # )
63
+ public_class_method def self.wire(opts = {})
64
+ engine = opts[:engine].to_s.downcase.to_sym
65
+ table = WIRE[engine] || {}
66
+ Array(opts[:names]).map do |name|
67
+ row = table[name.to_s]
68
+ raise ArgumentError, "#{engine} has no hosted tool #{name}" unless row
69
+
70
+ row.dup
71
+ end
72
+ end
73
+
74
+ # Supported Method Parameters::
75
+ # same = PWN::AI::ServerTools.mixable?(
76
+ # engine: 'required - provider name',
77
+ # model: 'optional - OpenAI model id; hosted tools share only a Responses request'
78
+ # )
79
+ public_class_method def self.mixable?(opts = {})
80
+ engine = opts[:engine].to_s.downcase.to_sym
81
+ return false unless %i[anthropic gemini openai].include?(engine)
82
+ return true unless engine == :openai
83
+
84
+ PWN::AI::OpenAI.api_endpoint(model: opts[:model]) == 'responses'
85
+ end
86
+
87
+ # Supported Method Parameters::
88
+ # body = PWN::AI::ServerTools.attach(
89
+ # engine: 'required - provider name',
90
+ # http_body: 'required - provider request body',
91
+ # request: 'required - original chat options, including server_tools'
92
+ # )
93
+ public_class_method def self.attach(opts = {})
94
+ body = opts[:http_body]
95
+ names = selected(request: opts[:request])
96
+ return body if names.empty?
97
+
98
+ engine = opts[:engine].to_s.downcase.to_sym
99
+ hosted = wire(engine: engine, names: names)
100
+ case engine
101
+ when :anthropic, :gemini, :openai
102
+ raise ArgumentError, 'OpenAI hosted tools require the Responses API' if engine == :openai && !body.key?(:input)
103
+
104
+ body[:tools] = Array(body[:tools]) + hosted
105
+ else
106
+ raise ArgumentError, "#{engine} cannot mix hosted tools into this request"
107
+ end
108
+ body
109
+ end
110
+
111
+ # Supported Method Parameters::
112
+ # result = PWN::AI::ServerTools.call(
113
+ # name: 'required - workspace tool name or hosted tool name',
114
+ # request: 'required - question or state to send with that tool',
115
+ # engine: 'optional - provider name; Ask Jev uses Open WebUI when that provider is configured',
116
+ # model: 'optional - overrides the provider model'
117
+ # )
118
+ public_class_method def self.call(opts = {})
119
+ name = opts[:name].to_s.strip
120
+ request = opts[:request].to_s
121
+ raise ArgumentError, 'request is required' if request.strip.empty?
122
+
123
+ engine = route(name: name, engine: opts[:engine])
124
+ return { available: false, engine: engine, reason: 'Ollama has no server-side tool workspace' } if engine == :ollama
125
+ return openwebui_call(name: name, request: request, model: opts[:model]) if engine == :openwebui
126
+
127
+ mod = Object.const_get(PWN::AI::Agent::Reflect::ENGINE_MODS.fetch(engine))
128
+ response = mod.chat_with_tools(
129
+ messages: [{ role: 'user', content: request }],
130
+ server_tools: [name],
131
+ model: opts[:model],
132
+ spinner: false
133
+ )
134
+ { available: true, engine: engine, name: name, content: answer_text(response: response) }
135
+ end
136
+
137
+ public_class_method def self.authors
138
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
139
+ end
140
+
141
+ public_class_method def self.help
142
+ puts "USAGE:
143
+ # Describe whether an engine has a workspace or only fixed hosted tools.
144
+ #{self}.catalog(
145
+ engine: 'required - openai, anthropic, gemini, grok, ollama, or openwebui',
146
+ model: 'optional - OpenAI model id used only to report whether hosted tools can share the request'
147
+ )
148
+
149
+ # List hosted tool names, or the installed Open WebUI workspace tools.
150
+ #{self}.list(
151
+ engine: 'required - provider name',
152
+ refresh: 'optional - true re-reads the Open WebUI workspace tool list',
153
+ model: 'optional - used to decide whether OpenAI hosted tools share the request'
154
+ )
155
+
156
+ # Translate hosted tool names into that provider's request objects.
157
+ #{self}.wire(
158
+ engine: 'required - provider name',
159
+ names: 'required - hosted tool names such as web_search or google_search'
160
+ )
161
+
162
+ # True when hosted tools can share the same request as Registry client tools.
163
+ #{self}.mixable?(
164
+ engine: 'required - provider name',
165
+ model: 'optional - OpenAI model id; hosted tools share only a Responses request'
166
+ )
167
+
168
+ # Append hosted tool objects to a provider body. Open WebUI is not mixed here.
169
+ #{self}.attach(
170
+ engine: 'required - provider name',
171
+ http_body: 'required - provider request body',
172
+ request: 'required - original chat options, including server_tools'
173
+ )
174
+
175
+ # Run one server-side tool and return its answer. Ask Jev uses Open WebUI.
176
+ #{self}.call(
177
+ name: 'required - workspace tool name or hosted tool name',
178
+ request: 'required - question or state to send with that tool',
179
+ engine: 'optional - provider name; Ask Jev uses Open WebUI when that provider is configured',
180
+ model: 'optional - overrides the provider model'
181
+ )
182
+
183
+ # Print the AUTHOR(S) string for this module.
184
+ #{self}.authors
185
+ "
186
+ end
187
+
188
+ private_class_method def self.selected(opts = {})
189
+ Array(opts.dig(:request, :server_tools)).map(&:to_s).reject(&:empty?)
190
+ end
191
+
192
+ private_class_method def self.route(opts = {})
193
+ name = opts[:name].to_s
194
+ engine = opts[:engine].to_s.downcase
195
+ engine = PWN::Env.dig(:ai, :active).to_s.downcase if engine.empty? && defined?(PWN::Env)
196
+ return :openwebui if name.match?(/ask[[:space:]]*jev/i) && openwebui_ready?
197
+
198
+ engine.empty? ? :openwebui : engine.to_sym
199
+ end
200
+
201
+ private_class_method def self.openwebui_ready?
202
+ engine = PWN::Env.dig(:ai, :openwebui) if defined?(PWN::Env)
203
+ return false unless engine.is_a?(Hash)
204
+
205
+ PWN::AI::OpenWebUI.send(:real_config_value?, value: engine[:base_uri]) &&
206
+ PWN::AI::OpenWebUI.send(:real_config_value?, value: engine[:key])
207
+ rescue StandardError
208
+ false
209
+ end
210
+
211
+ private_class_method def self.openwebui_call(opts = {})
212
+ name = opts[:name].to_s
213
+ listed = PWN::AI::OpenWebUI.workspace_tools
214
+ return { available: true, engine: :openwebui, tools: listed } if name.empty?
215
+
216
+ response = PWN::AI::OpenWebUI.chat(request: opts[:request], server_tools: [name], model: opts[:model], spinner: false)
217
+ { available: true, engine: :openwebui, name: name, content: answer_text(response: response), sources: response[:sources] || response['sources'] }
218
+ end
219
+
220
+ private_class_method def self.answer_text(opts = {})
221
+ response = opts[:response]
222
+ return '' unless response.is_a?(Hash)
223
+
224
+ message = response[:assistant_message] || response.dig(:choices, -1)
225
+ message = message[:message] if message.is_a?(Hash) && message[:message]
226
+ text = message.is_a?(Hash) ? (message[:content] || message['content']) : nil
227
+ text.to_s
228
+ end
229
+ end
230
+ end
231
+ end
data/lib/pwn/ai.rb CHANGED
@@ -19,6 +19,7 @@ module PWN
19
19
  autoload :OpenWebUI, 'pwn/ai/open_web_ui'
20
20
  autoload :RedTeam, 'pwn/ai/red_team'
21
21
  autoload :Router, 'pwn/ai/router'
22
+ autoload :ServerTools, 'pwn/ai/server_tools'
22
23
 
23
24
  # Display a List of Every PWN::AI Module
24
25
 
@@ -0,0 +1,347 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'digest'
6
+ require 'securerandom'
7
+ require 'find'
8
+
9
+ module PWN
10
+ # All first-party recovery copies share one serialized seven-generation store.
11
+ # Evidence artifacts are not recovery copies and must not enter this store.
12
+ module Backups
13
+ LIMIT = 7
14
+ GENERATION = /\A\d{8}_\d{6}_[a-f0-9]{8}\z/
15
+
16
+ # Supported Method Parameters::
17
+ # PWN::Backups.active?
18
+ public_class_method def self.active?
19
+ owner = Thread.current[:pwn_backup_transaction]
20
+ !owner.nil? && owner[:pid] == Process.pid && owner[:thread] == Thread.current
21
+ end
22
+
23
+ # Supported Method Parameters::
24
+ # PWN::Backups.transaction(root: nil) { ... }
25
+ public_class_method def self.transaction(opts = {})
26
+ owner = Thread.current[:pwn_backup_transaction]
27
+ override = Thread.current[:pwn_backup_root]
28
+ real_home = Thread.current[:pwn_backup_real_home]
29
+ override = nil if real_home && Dir.home != real_home
30
+ root = File.expand_path(opts[:root] || (owner[:root] if active?) || override || File.join(Dir.home, '.pwn', 'backup'))
31
+ return yield if owner && owner[:pid] == Process.pid && owner[:thread] == Thread.current && owner[:root] == root
32
+ raise IOError, 'Nested backup roots are not supported' if active?
33
+
34
+ learning = PWN::AI::Agent::Learning.send(:learning_lock)
35
+ safe_directory(path: root)
36
+ File.open(File.join(root, '.retention.lock'), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock|
37
+ raise IOError, 'Unsafe backup lock' unless lock.stat.uid == Process.uid && lock.stat.nlink == 1
38
+
39
+ lock.flock(File::LOCK_EX)
40
+ context = { pid: Process.pid, thread: Thread.current, root: root }
41
+ Thread.current[:pwn_backup_transaction] = context
42
+ begin
43
+ result = yield
44
+ failed = result.is_a?(Hash) && (result[:error] || result[:ok] == false || result[:promoted] == false || result[:incomplete] || %w[prepared interrupted blocked].include?(result[:state].to_s))
45
+ write_state(context: context, state: 'complete') if context[:directory] && !failed
46
+ result
47
+ ensure
48
+ # Exceptions leave a prepared recovery generation, never silently expire it.
49
+ Thread.current[:pwn_backup_transaction] = owner
50
+ end
51
+ end
52
+ ensure
53
+ PWN::AI::Agent::Learning.send(:learning_unlock, lock: learning) if learning
54
+ end
55
+
56
+ # Supported Method Parameters::
57
+ # PWN::Backups.directory(label: 'maintenance')
58
+ # Caller must hold transaction through the final source mutation and ledger.
59
+ public_class_method def self.directory(opts = {})
60
+ label = opts[:label].to_s
61
+ raise ArgumentError, 'Invalid backup label' unless label.match?(/\A[a-zA-Z0-9_-]+\z/)
62
+
63
+ context = Thread.current[:pwn_backup_transaction]
64
+ raise IOError, 'Backup transaction required' unless context && context[:pid] == Process.pid && context[:thread] == Thread.current
65
+
66
+ unless context[:directory]
67
+ root = context[:root]
68
+ names = Dir.children(root).select { |name| File.directory?(File.join(root, name)) || File.symlink?(File.join(root, name)) }.sort
69
+ pins = names.length >= LIMIT ? reference_pins(root: root) : []
70
+ raise IOError, 'Pinned backup missing' unless (pins - names).empty?
71
+
72
+ candidates = names.select do |name|
73
+ next false unless name.match?(GENERATION) && !pins.include?(name)
74
+
75
+ path = File.join(root, name)
76
+ safe_directory(path: path)
77
+ state = File.join(path, '.generation.json')
78
+ next false unless File.file?(state) && !File.symlink?(state)
79
+
80
+ record = JSON.parse(File.binread(state))
81
+ record['version'] == 1 && record['state'] == 'complete' && record['pinned'] != true
82
+ end
83
+ count = [names.length - LIMIT + 1, 0].max
84
+ raise IOError, 'Backup capacity occupied by legacy, pinned or interrupted generations' if candidates.length < count
85
+
86
+ candidates.sort_by { |name| File.mtime(File.join(root, name)).to_r }.first(count).each { |name| FileUtils.remove_entry_secure(File.join(root, name)) }
87
+ context[:directory] = File.join(root, "#{Time.now.utc.strftime('%Y%m%d_%H%M%S')}_#{SecureRandom.hex(4)}")
88
+ Dir.mkdir(context[:directory], 0o700)
89
+ write_state(context: context, state: 'prepared')
90
+ end
91
+ path = File.join(context[:directory], "#{label}-#{SecureRandom.hex(8)}")
92
+ Dir.mkdir(path, 0o700)
93
+ path
94
+ end
95
+
96
+ # Supported Method Parameters::
97
+ # PWN::Backups.capture(path: '/path/to/source', label: 'learning')
98
+ public_class_method def self.capture(opts = {})
99
+ source = File.expand_path(opts[:path])
100
+ label = opts[:label] || 'snapshot'
101
+ transaction do
102
+ Thread.current[:pwn_backup_transaction][:pinned] = true if opts[:pin]
103
+ raise IOError, 'Unsafe backup source' unless File.file?(source) && File.realpath(source) == source && File.stat(source).uid == Process.uid
104
+
105
+ directory = self.directory(label: label)
106
+ target = File.join(directory, File.basename(source))
107
+ before = Digest::SHA256.file(source).hexdigest
108
+ File.open(source, File::RDONLY | File::NOFOLLOW) do |input|
109
+ File.open(target, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |output|
110
+ IO.copy_stream(input, output)
111
+ output.flush
112
+ output.fsync
113
+ end
114
+ end
115
+ raise IOError, 'Backup source changed or copy failed' unless Digest::SHA256.file(source).hexdigest == before && Digest::SHA256.file(target).hexdigest == before
116
+
117
+ File.open(directory, File::RDONLY, &:fsync)
118
+ target
119
+ end
120
+ end
121
+
122
+ # Supported Method Parameters::
123
+ # PWN::Backups.configure(root: nil)
124
+ public_class_method def self.configure(opts = {})
125
+ root = opts[:root] || File.join(Dir.home, '.pwn', 'backup')
126
+ transaction(root: root) do
127
+ path = File.join(root, '.retention-policy.json')
128
+ raise IOError, 'Unsafe retention policy' if File.symlink?(path)
129
+
130
+ policy = File.file?(path) ? JSON.parse(File.binread(path)) : { 'version' => 1, 'pins' => [] }
131
+ raise IOError, 'Invalid retention policy' unless policy['version'] == 1 && policy['pins'].is_a?(Array)
132
+
133
+ updated = policy.merge('keep' => LIMIT, 'expire_history' => true, 'scope' => 'global')
134
+ next updated if updated == policy
135
+
136
+ temporary = "#{path}.#{SecureRandom.hex(8)}"
137
+ File.open(temporary, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
138
+ file.write(JSON.generate(updated))
139
+ file.flush
140
+ file.fsync
141
+ end
142
+ File.rename(temporary, path)
143
+ File.open(root, File::RDONLY, &:fsync)
144
+ updated
145
+ end
146
+ end
147
+
148
+ # Supported Method Parameters::
149
+ # PWN::Backups.reconcile(generation: 'name', expected_sha256: 'digest', confirmed: true)
150
+ public_class_method def self.reconcile(opts = {})
151
+ generation = opts[:generation].to_s
152
+ raise ArgumentError, 'Exact generation and explicit recovery confirmation required' unless generation.match?(GENERATION) && opts[:confirmed] == true
153
+
154
+ transaction do
155
+ root = Thread.current[:pwn_backup_transaction][:root]
156
+ directory = File.join(root, generation)
157
+ raise IOError, 'Recovery generation missing' unless File.directory?(directory)
158
+
159
+ safe_directory(path: directory)
160
+ path = File.join(directory, '.generation.json')
161
+ raise IOError, 'Recovery manifest changed' unless !File.symlink?(path) && Digest::SHA256.file(path).hexdigest == opts[:expected_sha256]
162
+
163
+ record = JSON.parse(File.read(path))
164
+ raise IOError, 'Unknown recovery manifest' unless record['version'] == 1 && %w[prepared complete].include?(record['state'])
165
+
166
+ write_state(context: { root: root, directory: directory, pinned: opts.fetch(:pinned, true) }, state: 'complete')
167
+ { generation: generation, state: 'complete', pinned: opts.fetch(:pinned, true) }
168
+ end
169
+ end
170
+
171
+ # Supported Method Parameters::
172
+ # PWN::Backups.relocate_legacy(paths: [], dry_run: true, writers_stopped: false)
173
+ public_class_method def self.relocate_legacy(opts = {})
174
+ paths = Array(opts[:paths]).map { |path| File.expand_path(path) }.uniq
175
+ dry_run = opts.fetch(:dry_run, true)
176
+ raise ArgumentError, 'Stop all legacy writers before relocation' unless dry_run || opts[:writers_stopped] == true
177
+
178
+ home = Dir.home
179
+ root = File.join(home, '.pwn')
180
+ paths.each do |path|
181
+ relative = path.delete_prefix("#{root}/")
182
+ recognized = relative.match?(%r{\A(?:learning\.jsonl\.bak[^/]*|preferences\.jsonl\.bak[^/]*|skills/.+/SKILL\.md\.[a-f0-9]{64}\.bak|policy\.json\.rollback-[a-f0-9]{64}\.json|learning/migrations/[a-f0-9]{64}\.backup\.jsonl|session_cleanup_archive/[^/]+|maintenance/(?:retirements|quarantine)/[^/]+)\z})
183
+ recognized ||= path == File.join(home, '.config', 'terminator', 'config.pwnbak')
184
+ raise ArgumentError, 'Unrecognized legacy recovery path' unless recognized
185
+ raise IOError, 'Unsafe legacy recovery path' unless File.realpath(path) == path && File.stat(path).uid == Process.uid
186
+ end
187
+ raise ArgumentError, 'Overlapping legacy sources' if paths.any? { |path| paths.any? { |other| path != other && path.start_with?("#{other}/") } }
188
+
189
+ return { planned: paths, moved: [] } if dry_run
190
+
191
+ transaction do
192
+ legacy_unreferenced(paths: paths, root: root)
193
+ mappings = paths.to_h do |path|
194
+ target = File.join(directory(label: 'legacy'), File.basename(path))
195
+ before = legacy_inventory(path: path)
196
+ FileUtils.cp_r(path, target, preserve: true)
197
+ raise IOError, 'Legacy recovery changed during copy' unless before == legacy_inventory(path: path) && before == legacy_inventory(path: target)
198
+
199
+ [path, { target: target, inventory: before }]
200
+ end
201
+ legacy_unreferenced(paths: paths, root: root)
202
+ mappings.each do |path, row|
203
+ raise IOError, 'Legacy recovery changed before relocation' unless row[:inventory] == legacy_inventory(path: path)
204
+ end
205
+ ledger = File.join(Thread.current[:pwn_backup_transaction][:directory], '.relocation.json')
206
+ File.open(ledger, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
207
+ file.write(JSON.generate(version: 1, mappings: mappings))
208
+ file.flush
209
+ file.fsync
210
+ end
211
+ mappings.each_key { |path| FileUtils.remove_entry_secure(path) }
212
+ { moved: mappings.transform_values { |row| row[:target] }, ledger: ledger }
213
+ end
214
+ end
215
+
216
+ private_class_method def self.legacy_inventory(opts = {})
217
+ path = opts[:path]
218
+ rows = {}
219
+ Find.find(path) do |file|
220
+ stat = File.lstat(file)
221
+ raise IOError, 'Unsafe legacy recovery entry' unless (stat.file? || stat.directory?) && stat.uid == Process.uid
222
+
223
+ rows[file.delete_prefix(path)] = [stat.ftype, stat.mode, stat.file? ? Digest::SHA256.file(file).hexdigest : nil]
224
+ File.open(file, File::RDONLY, &:fsync)
225
+ end
226
+ rows
227
+ end
228
+
229
+ private_class_method def self.legacy_unreferenced(opts = {})
230
+ paths = opts[:paths]
231
+ root = opts[:root]
232
+ return unless File.directory?(root)
233
+
234
+ Find.find(root) do |file|
235
+ Find.prune if file == File.join(root, 'backup') || paths.include?(file)
236
+ raise IOError, 'Unsafe legacy reference scope' if File.symlink?(file)
237
+ next unless File.file?(file) && file.match?(/\.jsonl?\z/)
238
+
239
+ body = File.binread(file)
240
+ raise IOError, 'Legacy recovery is referenced; preserve original paths' if paths.any? { |path| body.include?(path) || body.include?(path.delete_prefix("#{root}/")) }
241
+ end
242
+ end
243
+
244
+ private_class_method def self.reference_pins(opts = {})
245
+ root = opts[:root]
246
+ policy = File.join(root, '.retention-policy.json')
247
+ raise IOError, 'Unsafe retention policy' if File.symlink?(policy)
248
+
249
+ pins = File.file?(policy) ? JSON.parse(File.binread(policy)).fetch('pins') : []
250
+ raise IOError, 'Invalid backup pins' unless pins.is_a?(Array) && pins.all? { |name| name.is_a?(String) && name.match?(/\A\d{8}_\d{6}(?:_[a-f0-9]{8})?\z/) }
251
+
252
+ pins = pins.dup
253
+ state_root = File.dirname(root)
254
+ names = %w[learning learning.jsonl memory.json mistakes.json policy.json policy_traj.jsonl preferences.jsonl engagements artifacts sessions swarm jobs runs missions open_goals open_goal.json curriculum finetune extrospection skill_review.jsonl maintenance]
255
+ names.each do |name|
256
+ path = File.join(state_root, name)
257
+ next unless File.exist?(path) || File.symlink?(path)
258
+
259
+ Find.find(path) do |file|
260
+ stat = File.lstat(file)
261
+ raise IOError, 'Unsafe backup reference entry' unless (stat.file? || stat.directory?) && stat.uid == Process.uid
262
+ next unless stat.file? && file.match?(/\.jsonl?\z/)
263
+
264
+ admission = Thread.current[:pwn_maintenance_admission]
265
+ raise IOError, 'Backup reference budget exhausted' if admission && !admission.call(stat.size + 1)
266
+
267
+ values = file.end_with?('.jsonl') ? File.foreach(file).reject { |line| line.strip.empty? }.map { |line| JSON.parse(line) } : [JSON.parse(File.binread(file))]
268
+ values.select! { |value| value.is_a?(Hash) && (%w[prepared interrupted].include?(value['state']) || value['pinned'] == true) } if name == 'maintenance'
269
+ until values.empty?
270
+ value = values.pop
271
+ case value
272
+ when Hash then values.concat(value.values)
273
+ when Array then values.concat(value)
274
+ when String
275
+ match = value.match(%r{\A(?:#{Regexp.escape(root)}/|backup/)(\d{8}_\d{6}(?:_[a-f0-9]{8})?)(?:/|\z)})
276
+ pins << match[1] if match
277
+ end
278
+ end
279
+ end
280
+ end
281
+ pins.uniq
282
+ rescue JSON::ParserError, KeyError => e
283
+ raise IOError, "Unreadable backup references: #{e.class}"
284
+ end
285
+
286
+ private_class_method def self.safe_directory(opts = {})
287
+ path = opts[:path]
288
+ ancestor = path
289
+ loop do
290
+ raise IOError, 'Symlinked backup ancestor' if File.symlink?(ancestor)
291
+ break if File.dirname(ancestor) == ancestor
292
+
293
+ ancestor = File.dirname(ancestor)
294
+ end
295
+ FileUtils.mkdir_p(path, mode: 0o700)
296
+ stat = File.stat(path)
297
+ if stat.uid == Process.uid && !stat.mode.nobits?(0o077)
298
+ File.chmod(0o700, path)
299
+ stat = File.stat(path)
300
+ end
301
+ raise IOError, 'Unsafe backup directory' unless stat.uid == Process.uid && stat.mode.nobits?(0o077)
302
+
303
+ path
304
+ end
305
+
306
+ private_class_method def self.write_state(opts = {})
307
+ context = opts[:context]
308
+ path = File.join(context[:directory], '.generation.json')
309
+ temporary = "#{path}.tmp"
310
+ File.open(temporary, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
311
+ file.write(JSON.generate(version: 1, state: opts[:state], pinned: context[:pinned] == true))
312
+ file.flush
313
+ file.fsync
314
+ end
315
+ File.rename(temporary, path)
316
+ File.open(context[:directory], File::RDONLY, &:fsync)
317
+ File.open(context[:root], File::RDONLY, &:fsync)
318
+ end
319
+
320
+ # Author(s):: 0day Inc. <support@0dayinc.com>
321
+ public_class_method def self.authors
322
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
323
+ end
324
+
325
+ # Display Usage for this Module
326
+ public_class_method def self.help
327
+ puts "
328
+ # Hold the shared lock through backup and source mutation.
329
+ #{self}.transaction(root: 'optional - backup root; defaults to Thread.current[:pwn_backup_root] or ~/.pwn/backup') { ... }
330
+ # Allocate a producer directory inside the current generation.
331
+ #{self}.directory(label: 'required - producer label')
332
+ # Copy and hash-verify an owned regular file.
333
+ #{self}.capture(path: 'required - source path', label: 'optional - producer label', pin: 'optional - retain until explicitly reconciled')
334
+ # Install the global seven-folder policy without expiring existing data.
335
+ #{self}.configure(root: 'optional - backup root; defaults ~/.pwn/backup')
336
+ # Acknowledge completed store-specific recovery; this does not restore data.
337
+ #{self}.reconcile(generation: 'required - exact generation name', expected_sha256: 'required - inspected generation manifest digest', confirmed: 'required - explicit true after store recovery', pinned: 'optional - retain by default; false permits ordinary retention')
338
+ # Explicitly relocate recognized unreferenced legacy recovery copies.
339
+ #{self}.relocate_legacy(paths: 'required - exact legacy paths', dry_run: 'optional - preview only by default', writers_stopped: 'optional - must be true to apply')
340
+ # Check whether this thread owns the backup transaction.
341
+ #{self}.active?
342
+ # Show module authors.
343
+ #{self}.authors
344
+ "
345
+ end
346
+ end
347
+ end