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,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tmpdir'
5
+ require 'stringio'
6
+
7
+ RSpec.describe PWN::Plugins::REPL::Vault, 'hardware pwn-vault command' do
8
+ around do |example|
9
+ Dir.mktmpdir('repl-vault-yubikey-') do |root|
10
+ @root = root
11
+ example.run
12
+ end
13
+ end
14
+
15
+ before do
16
+ allow(Dir).to receive(:home).and_return(@root)
17
+ stub_const('PWN::Plugins::YubiKey', double('synthetic YubiKey'))
18
+ allow(PWN::Plugins::YubiKey).to receive(:challenge_response) do |opts|
19
+ OpenSSL::HMAC.digest('SHA1', 'synthetic-slot-secret', opts[:challenge])
20
+ end
21
+ @path = File.join(@root, 'selected-vault')
22
+ original = { ai: { active: 'ollama', ollama: { model: 'fixture-model' } } }
23
+ File.binwrite(@path, PWN::Plugins::Vault.encrypt_data(data: YAML.dump(original), yubikey: { slot: 2 }))
24
+ stub_const('PWN::Env', { driver_opts: { pwn_env_path: @path } })
25
+ described_class.add_commands
26
+ @command = Pry::Commands.find_command('pwn-vault').new(pry_instance: Pry.new, output: StringIO.new)
27
+ end
28
+
29
+ it 'edits and refreshes a hardware vault without requiring a sidecar or passing software keys' do
30
+ expect(PWN::Plugins::Vault).to receive(:edit).with({ file: @path, yubikey: { slot: 2 } }) do |opts|
31
+ PWN::Plugins::Vault.update(opts) { |cfg| cfg[:ai][:ollama][:model] = 'new-model' }
32
+ end
33
+ expect(PWN::Config).to receive(:refresh_env).with({ pwn_env_path: @path, pwn_dec_path: "#{@path}.decryptor", yubikey: { slot: 2 } })
34
+ @command.process
35
+ expect(PWN::Plugins::Vault.dump(file: @path).dig(:ai, :ollama, :model)).to eq('new-model')
36
+ expect(PWN::Plugins::Vault.hardware_encrypted?(file: @path)).to be(true)
37
+ expect(Dir.children(@root)).to eq(['selected-vault'])
38
+ end
39
+ end
@@ -17,7 +17,14 @@ describe PWN::Plugins::REPL do # rubocop:disable Metrics/BlockLength
17
17
  File.write("#{path}.decryptor", YAML.dump(key: key, iv: iv))
18
18
  PWN::Plugins::Vault.encrypt(file: path, key: key, iv: iv)
19
19
  PWN::Env[:driver_opts] = { pwn_env_path: path, pwn_dec_path: "#{path}.decryptor" }
20
+ expect(PWN::Plugins::Vault).not_to receive(:decrypt)
21
+ allow(File).to receive(:write).and_wrap_original do |method, target, bytes, *args|
22
+ expect(bytes.to_s).not_to include('new role')
23
+ method.call(target, bytes, *args)
24
+ end
20
25
  expect(described_class.pwn_ai_apply_system_role(engine: 'openai', content: "new role\nsecond line")).to eq(:saved)
26
+ RSpec::Mocks.space.proxy_for(PWN::Plugins::Vault).reset
27
+ RSpec::Mocks.space.proxy_for(File).reset
21
28
  expect(PWN::Env.dig(:ai, :openai, :system_role_content)).to eq("new role\nsecond line")
22
29
  expect(File.read(path)).not_to include('new role')
23
30
  PWN::Plugins::Vault.decrypt(file: path, key: key, iv: iv)
@@ -42,7 +49,11 @@ describe PWN::Plugins::REPL do # rubocop:disable Metrics/BlockLength
42
49
  PWN::Plugins::Vault.encrypt(file: path, key: key, iv: iv)
43
50
  ciphertext = File.binread(path)
44
51
  PWN::Env[:driver_opts] = { pwn_env_path: path, pwn_dec_path: "#{path}.decryptor" }
45
- allow(PWN::Plugins::Vault).to receive(:encrypt).and_raise(IOError, 'fixture encryption failure')
52
+ allow(OpenSSL::Cipher).to receive(:new).and_wrap_original do |method, *args|
53
+ cipher = method.call(*args)
54
+ allow(cipher).to receive(:encrypt).and_raise(IOError, 'fixture encryption failure')
55
+ cipher
56
+ end
46
57
  expect { described_class.pwn_ai_apply_system_role(engine: 'openai', content: 'unsaved') }.to raise_error(/not saved/)
47
58
  expect(PWN::Env.dig(:ai, :openai, :system_role_content)).to eq('original')
48
59
  expect(File.binread(path)).to eq(ciphertext)
@@ -51,6 +62,47 @@ describe PWN::Plugins::REPL do # rubocop:disable Metrics/BlockLength
51
62
  PWN::Env[:driver_opts] = previous_driver_opts
52
63
  end
53
64
 
65
+ it 'leaves no plaintext staging bytes when killed at encryption startup' do
66
+ previous_driver_opts = PWN::Env[:driver_opts]
67
+ path = File.join(@tmp, 'kill-model.yaml')
68
+ key = Base64.strict_encode64('k' * 32)
69
+ iv = Base64.strict_encode64('i' * 16)
70
+ File.write(path, YAML.dump(ai: { active: 'openai' }, fixture: 'PRIVATE_FIXTURE_SENTINEL'))
71
+ File.write("#{path}.decryptor", YAML.dump(key: key, iv: iv))
72
+ PWN::Plugins::Vault.encrypt(file: path, key: key, iv: iv)
73
+ ciphertext = File.binread(path)
74
+ PWN::Env[:driver_opts] = { pwn_env_path: path }
75
+ reader, writer = IO.pipe
76
+ pid = fork do
77
+ reader.close
78
+ allow(OpenSSL::Cipher).to receive(:new).and_wrap_original do |method, *args|
79
+ cipher = method.call(*args)
80
+ allow(cipher).to receive(:encrypt) do
81
+ writer.write('ready')
82
+ sleep 60
83
+ end
84
+ cipher
85
+ end
86
+ described_class.persist_ai_selection(engine: 'openai', model: 'offline')
87
+ exit! 1
88
+ end
89
+ writer.close
90
+ Timeout.timeout(5) { expect(reader.read(5)).to eq('ready') }
91
+ Process.kill('KILL', pid)
92
+ Process.wait(pid)
93
+ pid = nil
94
+ expect(File.binread(path)).to eq(ciphertext)
95
+ # Shared Vault.update encrypts before allocating a ciphertext-only stage.
96
+ stages = Dir.glob(File.join(@tmp, '{.pwn-ai-selection*,.pwn-vault-*}'))
97
+ expect(stages).to be_empty
98
+ ensure
99
+ Process.kill('KILL', pid) if pid
100
+ Process.wait(pid) if pid
101
+ reader&.close
102
+ writer&.close unless writer&.closed?
103
+ PWN::Env[:driver_opts] = previous_driver_opts
104
+ end
105
+
54
106
  it 'rejects role saves without a decryptor or after switching the active engine' do
55
107
  previous_driver_opts = PWN::Env[:driver_opts]
56
108
  PWN::Env[:driver_opts] = { pwn_env_path: File.join(@tmp, 'missing.yaml'), pwn_dec_path: File.join(@tmp, 'missing.decryptor') }
@@ -783,15 +835,14 @@ describe PWN::Plugins::REPL do # rubocop:disable Metrics/BlockLength
783
835
  yaml = File.join(dir, 'pwn.yaml')
784
836
  dec = "#{yaml}.decryptor"
785
837
  File.write(yaml, YAML.dump({ plugins: { meshtastic: { transport: 'mqtt', channel: { active: 'LongFast' } } } }))
786
- File.write(dec, YAML.dump({ key: 'k' * 32, iv: 'i' * 16 }))
787
- allow(PWN::Plugins::Vault).to receive(:decrypt)
788
- allow(PWN::Plugins::Vault).to receive(:encrypt)
838
+ PWN::Plugins::Vault.create(file: yaml, decryptor_file: dec)
839
+ credentials = YAML.safe_load_file(dec, symbolize_names: true)
789
840
  prev = PWN::Env[:driver_opts]
790
841
  PWN::Env[:driver_opts] = { pwn_env_path: yaml, pwn_dec_path: dec }
791
842
  described_class.persist_mesh_env(
792
843
  mesh: { transport: 'serial', channel: { active: 'LongFast' }, serial: { port: '/dev/ttyACM0' } }
793
844
  )
794
- cfg = YAML.load_file(yaml, symbolize_names: true)
845
+ cfg = PWN::Plugins::Vault.dump({ file: yaml }.merge(credentials))
795
846
  expect(cfg.dig(:plugins, :meshtastic, :transport).to_s).to eq('serial')
796
847
  expect(cfg.dig(:plugins, :meshtastic, :channel, :active).to_s).to eq('LongFast')
797
848
  expect(cfg.dig(:plugins, :meshtastic, :serial, :port)).to eq('/dev/ttyACM0')
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe PWN::Plugins::TransparentBrowser, 'jev action loop' do
6
+ let(:browser) { double('browser', goto: nil) }
7
+ let(:browser_obj) { { type: :headless, browser: browser } }
8
+
9
+ before do
10
+ Thread.current[:pwn_jev_model] = nil
11
+ allow(PWN::Env).to receive(:dig).and_call_original
12
+ allow(PWN::Env).to receive(:dig).with(:ai, :openwebui).and_return({ base_uri: 'https://ow.example', key: 'token', model: 'llama' })
13
+ allow(PWN::Env).to receive(:dig).with(:ai, :active).and_return(:grok)
14
+ allow(PWN::Env).to receive(:dig).with(:ai, :grok, :model).and_return('grok-4')
15
+ allow(PWN::AI::OpenWebUI).to receive(:get_models).and_return([{ id: 'jev-27b' }])
16
+ end
17
+
18
+ after { Thread.current[:pwn_jev_model] = nil }
19
+
20
+ it 'stays inactive when Open WebUI has no Jev model' do
21
+ allow(PWN::AI::OpenWebUI).to receive(:get_models).and_return([{ id: 'llama' }])
22
+ expect(described_class.act(browser_obj: browser_obj, goal: 'find the article')).to include(available: false, status: 'unavailable')
23
+ end
24
+
25
+ it 'asks Jev for the operation and the active engine only for typed text' do
26
+ scripts = []
27
+ allow(browser).to receive(:execute_script) do |script|
28
+ if script.include?('querySelectorAll')
29
+ { 'url' => 'https://example.test', 'title' => 'Example', 'rows' => [{ 'index' => 1, 'role' => 'textbox', 'name' => 'Where to?', 'value' => '' }] }
30
+ else
31
+ scripts << script
32
+ { 'status' => scripts.length == 1 ? 'acted' : 'done' }
33
+ end
34
+ end
35
+ decisions = ['{"operation":"TYPE_TEXT","target":1}', '{"operation":"DONE","target":0}']
36
+ allow(PWN::AI::OpenWebUI).to receive(:chat) { { choices: [{ role: 'assistant', content: decisions.shift }] } }
37
+ allow(PWN::AI::Grok).to receive(:chat).and_return({ choices: [{ role: 'assistant', content: '{"text":"London"}' }] })
38
+
39
+ result = described_class.act(browser_obj: browser_obj, goal: 'Search for London', steps: 2)
40
+
41
+ expect(result).to include(available: true, status: 'done', jev_model: 'jev-27b', text_engine: :grok, text_model: 'grok-4')
42
+ expect(PWN::AI::Grok).to have_received(:chat)
43
+ expect(scripts.first).to include('London')
44
+ expect(scripts.first).not_to include('querySelectorAll')
45
+ end
46
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe PWN::Plugins::Vault, 'native YubiKey selectors' do
6
+ before do
7
+ @device = double('offline native YubiKey')
8
+ stub_const('PWN::Plugins::YubiKey', @device)
9
+ allow(@device).to receive(:challenge_response) { |opts| OpenSSL::HMAC.digest('SHA1', 'fixture-slot', opts[:challenge]) }
10
+ end
11
+
12
+ it 'forwards caller-owned native controls unchanged and persists only public slot and serial selectors' do
13
+ connection = double('native connection', exchange: ''.b)
14
+ selector = { slot: 1, serial: '001234', device_path: '/fixture/native-device', connection: connection, timeout: 1.5 }.freeze
15
+ expect(@device).to receive(:challenge_response).with(hash_including(selector)).twice
16
+ envelope = described_class.seal_credentials(credentials: { token: 'fixture' }, yubikey: selector)
17
+ expect(envelope.fetch('yubikey').keys).to contain_exactly('slot', 'serial', 'challenge')
18
+ expect(envelope.fetch('yubikey')).to include('slot' => 1, 'serial' => '001234')
19
+ expect(described_class.open_credentials(envelope: envelope, yubikey: selector)).to eq('token' => 'fixture')
20
+ end
21
+
22
+ it 'accepts an exact caller-owned smartcard reader without serial and never persists it' do
23
+ selector = { slot: 2, reader: ' Fixture NFC reader ' }.freeze
24
+ expect(@device).to receive(:challenge_response).with(hash_including(selector)).twice
25
+ envelope = described_class.seal_credentials(credentials: {}, yubikey: selector)
26
+ expect(envelope.fetch('yubikey').keys).to contain_exactly('slot', 'challenge')
27
+ expect(described_class.open_credentials(envelope: envelope, yubikey: selector)).to eq({})
28
+ end
29
+ end
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tmpdir'
5
+ require 'timeout'
6
+
7
+ RSpec.describe PWN::Plugins::Vault, 'serialized publication safety' do
8
+ before do
9
+ @root = Dir.mktmpdir('vault-publication-safety-')
10
+ allow(Dir).to receive(:home).and_return(@root)
11
+ @device = double('offline YubiKey')
12
+ stub_const('PWN::Plugins::YubiKey', @device)
13
+ allow(@device).to receive(:challenge_response) { |opts| OpenSSL::HMAC.digest('SHA1', 'fixture-slot', opts[:challenge]) }
14
+ @path = File.join(@root, 'vault')
15
+ @legacy = { key: Base64.strict_encode64('k' * 32), iv: Base64.strict_encode64('i' * 16) }
16
+ @document = { token: 'fixture', retained: 'original' }
17
+ @threads = []
18
+ end
19
+
20
+ after do
21
+ @threads.each do |thread|
22
+ thread.kill if thread.alive?
23
+ thread.join(1)
24
+ end
25
+ FileUtils.remove_entry(@root)
26
+ end
27
+
28
+ def writer_thread(role, &block)
29
+ thread = Thread.new do
30
+ Thread.current.report_on_exception = false
31
+ Thread.current[:vault_publication_role] = role
32
+ block.call
33
+ end
34
+ @threads << thread
35
+ thread
36
+ end
37
+
38
+ def prepare_vault(backend = :hardware)
39
+ access = backend == :hardware ? { yubikey: {} } : @legacy
40
+ File.binwrite(@path, described_class.encrypt_data(access.merge(data: YAML.dump(@document))))
41
+ end
42
+
43
+ def fake_editor
44
+ allow(File).to receive(:executable?).and_call_original
45
+ allow(File).to receive(:executable?).with('/offline-editor').and_return(true)
46
+ allow(described_class).to receive(:system).with('/offline-editor', anything) do |_editor, path|
47
+ File.write(path, YAML.dump(@document.merge(edited: true)))
48
+ true
49
+ end
50
+ end
51
+
52
+ %i[refresh_encryption_secrets edit].each do |writer|
53
+ it "serializes #{writer} against update even when paused after its baseline check", :aggregate_failures do
54
+ prepare_vault
55
+ fake_editor if writer == :edit
56
+ ready = Queue.new
57
+ release = Queue.new
58
+ attempts = Queue.new
59
+ allow(File).to receive(:rename).and_wrap_original do |rename, from, to|
60
+ if to == @path && Thread.current[:vault_publication_role] == :first
61
+ ready << true
62
+ release.pop
63
+ end
64
+ rename.call(from, to)
65
+ end
66
+ # Probe the updater's actual flock before blocking. This makes the old
67
+ # unchecked-rename interleaving deterministic, without sleeps or polling.
68
+ allow(File).to receive(:open).and_wrap_original do |open, *args, **kwargs, &block|
69
+ if args == [@root, File::RDONLY] && Thread.current[:vault_publication_role] == :updater
70
+ open.call(*args) do |lock|
71
+ allow(lock).to receive(:flock).and_wrap_original do |flock, operation|
72
+ if operation == File::LOCK_EX
73
+ acquired = flock.call(operation | File::LOCK_NB)
74
+ attempts << (acquired ? :acquired : :blocked)
75
+ acquired || flock.call(operation)
76
+ else
77
+ flock.call(operation)
78
+ end
79
+ end
80
+ block.call(lock)
81
+ end
82
+ else
83
+ open.call(*args, **kwargs, &block)
84
+ end
85
+ end
86
+ attempt = nil
87
+ Timeout.timeout(5) do
88
+ first = writer_thread(:first) { described_class.public_send(writer, file: @path, editor: '/offline-editor') }
89
+ ready.pop
90
+ updater = writer_thread(:updater) { described_class.update(file: @path) { |cfg| cfg[:updated] = true } }
91
+ attempt = attempts.pop
92
+ updater.value if attempt == :acquired
93
+ release << true
94
+ first.value
95
+ expect(updater.value).to eq(:saved)
96
+ end
97
+ expect(attempt).to eq(:blocked)
98
+ expected = @document.merge(updated: true)
99
+ expected[:edited] = true if writer == :edit
100
+ expect(described_class.dump(file: @path)).to eq(expected)
101
+ expect(Dir.glob(File.join(@root, '.pwn-vault-*'))).to be_empty
102
+ end
103
+ end
104
+
105
+ %i[update refresh_encryption_secrets edit encrypt decrypt create legacy_refresh legacy_create].each do |writer|
106
+ it "holds the shared directory lock through every #{writer} rename" do
107
+ if %i[encrypt create legacy_create].include?(writer)
108
+ File.write(@path, YAML.dump(@document))
109
+ else
110
+ prepare_vault(writer == :legacy_refresh ? :legacy : :hardware)
111
+ end
112
+ fake_editor if writer == :edit
113
+ publications = 0
114
+ allow(File).to receive(:rename).and_wrap_original do |rename, from, to|
115
+ if to == @path
116
+ File.open(@root, File::RDONLY) do |probe|
117
+ expect(probe.flock(File::LOCK_EX | File::LOCK_NB)).to be(false)
118
+ end
119
+ publications += 1
120
+ end
121
+ rename.call(from, to)
122
+ end
123
+ Timeout.timeout(5) do
124
+ case writer
125
+ when :update
126
+ described_class.update(file: @path) { |cfg| cfg[:updated] = true }
127
+ when :legacy_refresh
128
+ described_class.refresh_encryption_secrets(@legacy.merge(file: @path))
129
+ when :legacy_create
130
+ described_class.create(file: @path, decryptor_file: "#{@path}.decryptor")
131
+ when :create, :encrypt
132
+ described_class.public_send(writer, file: @path, yubikey: {})
133
+ else
134
+ described_class.public_send(writer, file: @path, editor: '/offline-editor')
135
+ end
136
+ end
137
+ expect(publications).to be_positive
138
+ end
139
+ end
140
+
141
+ it 'allows nested updates in one directory without reacquiring its own lock' do
142
+ prepare_vault
143
+ sibling = File.join(@root, 'sibling')
144
+ File.binwrite(sibling, File.binread(@path))
145
+ Timeout.timeout(5) do
146
+ described_class.update(file: @path) do |cfg|
147
+ described_class.update(file: sibling) { |other| other[:nested] = true }
148
+ cfg[:outer] = true
149
+ end
150
+ end
151
+ expect(described_class.dump(file: @path)).to eq(@document.merge(outer: true))
152
+ expect(described_class.dump(file: sibling)).to eq(@document.merge(nested: true))
153
+ end
154
+
155
+ it 'retains expected-byte conflict detection for nested writes to the same vault' do
156
+ prepare_vault
157
+ Timeout.timeout(5) do
158
+ expect do
159
+ described_class.update(file: @path) do |cfg|
160
+ described_class.refresh_encryption_secrets(file: @path)
161
+ cfg[:stale] = true
162
+ end
163
+ end.to raise_error(IOError, /changed before replacement/)
164
+ end
165
+ expect(described_class.dump(file: @path)).to eq(@document)
166
+ end
167
+
168
+ it 'does not overwrite changes from a writer that bypasses the advisory lock' do
169
+ prepare_vault
170
+ replacement = described_class.encrypt_data(data: YAML.dump(@document.merge(external: true)), yubikey: {})
171
+ expect do
172
+ described_class.update(file: @path) do |cfg|
173
+ File.binwrite(@path, replacement)
174
+ cfg[:stale] = true
175
+ end
176
+ end.to raise_error(IOError, /changed before replacement/)
177
+ expect(File.binread(@path)).to eq(replacement)
178
+ expect(Dir.children(@root)).to eq(['vault'])
179
+ end
180
+
181
+ it 'recognizes directory aliases when reentering its own publication lock' do
182
+ prepare_vault
183
+ sibling = File.join(@root, 'sibling')
184
+ File.binwrite(sibling, File.binread(@path))
185
+ File.symlink(@root, File.join(@root, 'alias'))
186
+ Timeout.timeout(5) do
187
+ described_class.update(file: @path) do |cfg|
188
+ described_class.update(file: File.join(@root, 'alias', 'sibling')) { |other| other[:aliased] = true }
189
+ cfg[:outer] = true
190
+ end
191
+ end
192
+ expect(described_class.dump(file: sibling)).to eq(@document.merge(aliased: true))
193
+ expect(described_class.dump(file: @path)).to eq(@document.merge(outer: true))
194
+ end
195
+
196
+ it 'does not treat a lock inherited across fork as owned by the child' do
197
+ prepare_vault
198
+ reader, writer = IO.pipe
199
+ pid = nil
200
+ Timeout.timeout(8) do
201
+ described_class.update(file: @path) do |cfg|
202
+ pid = fork do
203
+ reader.close
204
+ allow(File).to receive(:open).and_wrap_original do |open, *args, **kwargs, &block|
205
+ if args == [@root, File::RDONLY]
206
+ open.call(*args) do |lock|
207
+ allow(lock).to receive(:flock).and_wrap_original do |flock, operation|
208
+ if operation == File::LOCK_EX
209
+ acquired = flock.call(operation | File::LOCK_NB)
210
+ writer.puts(acquired ? 'acquired' : 'blocked')
211
+ acquired || flock.call(operation)
212
+ else
213
+ flock.call(operation)
214
+ end
215
+ end
216
+ block.call(lock)
217
+ end
218
+ else
219
+ open.call(*args, **kwargs, &block)
220
+ end
221
+ end
222
+ begin
223
+ described_class.update(file: @path) { |child| child[:child] = true }
224
+ writer.puts('saved')
225
+ exit! 0
226
+ rescue StandardError, RSpec::Mocks::MockExpectationError
227
+ writer.puts('failed')
228
+ exit! 1
229
+ end
230
+ end
231
+ writer.close
232
+ expect(reader.gets).to eq("blocked\n")
233
+ cfg[:parent] = true
234
+ end
235
+ expect(reader.gets).to eq("saved\n")
236
+ _, status = Process.waitpid2(pid)
237
+ pid = nil
238
+ expect(status.success?).to be(true)
239
+ end
240
+ expect(described_class.dump(file: @path)).to eq(@document.merge(parent: true, child: true))
241
+ ensure
242
+ if pid
243
+ Process.kill('KILL', pid)
244
+ Process.waitpid(pid)
245
+ end
246
+ reader&.close unless reader&.closed?
247
+ writer&.close unless writer&.closed?
248
+ end
249
+
250
+ it 'preserves original ciphertext and removes staging files when publication fails' do
251
+ prepare_vault
252
+ original = File.binread(@path)
253
+ allow(File).to receive(:rename).and_raise(IOError, 'fixture rename failure')
254
+ expect { described_class.update(file: @path) { |cfg| cfg[:changed] = true } }.to raise_error(IOError, 'fixture rename failure')
255
+ expect(File.binread(@path)).to eq(original)
256
+ expect(Dir.children(@root)).to eq(['vault'])
257
+ end
258
+ end