pwn 0.5.784 → 0.5.787

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (254) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/Gemfile +6 -6
  4. data/README.md +37 -0
  5. data/bin/pwn +2 -1
  6. data/bin/pwn_web_cache_deception +35 -7
  7. data/build_gem.sh +1 -1
  8. data/documentation/AI-Integration.md +7 -5
  9. data/documentation/Agent-Tool-Registry.md +2 -2
  10. data/documentation/Backup-Retention.md +69 -15
  11. data/documentation/Configuration.md +10 -6
  12. data/documentation/Contributing.md +1 -1
  13. data/documentation/Cron.md +2 -2
  14. data/documentation/Diagrams.md +2 -2
  15. data/documentation/General-PWN-Usage.md +4 -3
  16. data/documentation/Harness-Evaluation.md +1 -1
  17. data/documentation/Home.md +6 -5
  18. data/documentation/How-PWN-Works.md +6 -5
  19. data/documentation/Installation.md +12 -9
  20. data/documentation/Mission-Cancellation.md +134 -0
  21. data/documentation/Operability.md +18 -0
  22. data/documentation/Persistence.md +7 -5
  23. data/documentation/Plugin-Degradation.md +14 -0
  24. data/documentation/Plugins.md +3 -2
  25. data/documentation/Policy-Benchmark.md +32 -33
  26. data/documentation/Runtime-Source-Protection.md +162 -0
  27. data/documentation/Transparent-Browser.md +11 -0
  28. data/documentation/Troubleshooting.md +3 -2
  29. data/documentation/What-is-PWN.md +2 -2
  30. data/documentation/YubiKey-Vaults.md +185 -0
  31. data/documentation/YubiKey.md +218 -0
  32. data/documentation/diagrams/cron-scheduling.svg +130 -130
  33. data/documentation/diagrams/dot/cron-scheduling.dot +3 -3
  34. data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
  35. data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
  36. data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +1 -1
  37. data/documentation/diagrams/dot/reinforcement-learning.dot +2 -2
  38. data/documentation/diagrams/persistence-filesystem.svg +172 -142
  39. data/documentation/diagrams/plugin-ecosystem.svg +64 -64
  40. data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +1 -1
  41. data/documentation/diagrams/reinforcement-learning.svg +89 -89
  42. data/documentation/pwn-ai-Agent.md +10 -4
  43. data/etc/default_cron/session_cleanup.rb +43 -27
  44. data/etc/default_skills/pwn/ai/agent/mission_namespace/SKILL.md +21 -0
  45. data/etc/default_skills/pwn/ai/agent/tools/browser_act/SKILL.md +45 -0
  46. data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +27 -26
  47. data/etc/default_skills/pwn/ai/agent/tools/recon_run/SKILL.md +25 -15
  48. data/etc/default_skills/pwn/ai/agent/tools/workspace_tool/SKILL.md +24 -0
  49. data/etc/default_skills/pwn/ai/server_tools/SKILL.md +25 -0
  50. data/etc/default_skills/pwn/backups/SKILL.md +54 -0
  51. data/etc/default_skills/pwn/plugins/recon/SKILL.md +12 -1
  52. data/etc/default_skills/pwn/plugins/yubikey/SKILL.md +157 -0
  53. data/etc/default_skills/pwn/plugins/yubikey/fido/SKILL.md +189 -0
  54. data/etc/default_skills/pwn/plugins/yubikey/hsmauth/SKILL.md +83 -0
  55. data/etc/default_skills/pwn/plugins/yubikey/management/SKILL.md +80 -0
  56. data/etc/default_skills/pwn/plugins/yubikey/oath/SKILL.md +115 -0
  57. data/etc/default_skills/pwn/plugins/yubikey/openpgp/SKILL.md +144 -0
  58. data/etc/default_skills/pwn/plugins/yubikey/otp/SKILL.md +97 -0
  59. data/etc/default_skills/pwn/plugins/yubikey/piv/SKILL.md +110 -0
  60. data/etc/default_skills/pwn/plugins/yubikey/protocol/SKILL.md +21 -0
  61. data/etc/default_skills/pwn/plugins/yubikey/security_domain/SKILL.md +75 -0
  62. data/etc/default_skills/pwn/plugins/yubikey/security_domain/scp/SKILL.md +46 -0
  63. data/etc/default_skills/pwn/plugins/yubikey/transport/SKILL.md +98 -0
  64. data/etc/default_skills/pwn/redaction/SKILL.md +13 -1
  65. data/lib/pwn/ai/agent/dispatch.rb +26 -15
  66. data/lib/pwn/ai/agent/learning.rb +14 -18
  67. data/lib/pwn/ai/agent/loop.rb +17 -1
  68. data/lib/pwn/ai/agent/mission_namespace.rb +331 -0
  69. data/lib/pwn/ai/agent/policy_evaluation.rb +334 -36
  70. data/lib/pwn/ai/agent/prompt_builder.rb +13 -2
  71. data/lib/pwn/ai/agent/request_runtime.rb +122 -18
  72. data/lib/pwn/ai/agent/reward.rb +5 -3
  73. data/lib/pwn/ai/agent/skill_consolidation.rb +3 -2
  74. data/lib/pwn/ai/agent/skill_review.rb +9 -5
  75. data/lib/pwn/ai/agent/swarm.rb +4 -0
  76. data/lib/pwn/ai/agent/tools/browser_act.rb +32 -0
  77. data/lib/pwn/ai/agent/tools/loot_query.rb +11 -1
  78. data/lib/pwn/ai/agent/tools/recon_run.rb +11 -1
  79. data/lib/pwn/ai/agent/tools/workspace_tool.rb +29 -0
  80. data/lib/pwn/ai/agent/turn_finalizer.rb +6 -2
  81. data/lib/pwn/ai/agent.rb +1 -0
  82. data/lib/pwn/ai/anthropic.rb +8 -33
  83. data/lib/pwn/ai/cli.rb +34 -5
  84. data/lib/pwn/ai/gemini.rb +1 -0
  85. data/lib/pwn/ai/grok.rb +39 -28
  86. data/lib/pwn/ai/ollama.rb +4 -2
  87. data/lib/pwn/ai/open_ai.rb +11 -32
  88. data/lib/pwn/ai/open_web_ui.rb +62 -4
  89. data/lib/pwn/ai/router.rb +8 -1
  90. data/lib/pwn/ai/server_tools.rb +231 -0
  91. data/lib/pwn/ai.rb +1 -0
  92. data/lib/pwn/backups.rb +349 -0
  93. data/lib/pwn/config.rb +70 -39
  94. data/lib/pwn/migrate.rb +85 -280
  95. data/lib/pwn/module_skills.rb +7 -0
  96. data/lib/pwn/plugins/ai_sandbox.rb +538 -1
  97. data/lib/pwn/plugins/authentication_helper.rb +74 -4
  98. data/lib/pwn/plugins/basic_auth.rb +2 -1
  99. data/lib/pwn/plugins/google_workspace.rb +4 -21
  100. data/lib/pwn/plugins/jobs.rb +1 -1
  101. data/lib/pwn/plugins/log.rb +3 -1
  102. data/lib/pwn/plugins/preflight_checker.rb +34 -9
  103. data/lib/pwn/plugins/recon.rb +4 -1
  104. data/lib/pwn/plugins/repl/ai.rb +293 -26
  105. data/lib/pwn/plugins/repl/ai_console.rb +268 -88
  106. data/lib/pwn/plugins/repl/ai_swarm.rb +177 -58
  107. data/lib/pwn/plugins/repl/mesh.rb +14 -19
  108. data/lib/pwn/plugins/repl/vault.rb +4 -15
  109. data/lib/pwn/plugins/repl.rb +9 -216
  110. data/lib/pwn/plugins/sandbox/driver.rb +4 -0
  111. data/lib/pwn/plugins/transparent_browser.rb +215 -0
  112. data/lib/pwn/plugins/vault.rb +412 -135
  113. data/lib/pwn/plugins/yubikey/fido.rb +1288 -0
  114. data/lib/pwn/plugins/yubikey/hsmauth.rb +439 -0
  115. data/lib/pwn/plugins/yubikey/management.rb +686 -0
  116. data/lib/pwn/plugins/yubikey/oath.rb +766 -0
  117. data/lib/pwn/plugins/yubikey/openpgp.rb +1063 -0
  118. data/lib/pwn/plugins/yubikey/otp.rb +897 -0
  119. data/lib/pwn/plugins/yubikey/piv.rb +1315 -0
  120. data/lib/pwn/plugins/yubikey/protocol.rb +341 -0
  121. data/lib/pwn/plugins/yubikey/security_domain/scp.rb +482 -0
  122. data/lib/pwn/plugins/yubikey/security_domain.rb +464 -0
  123. data/lib/pwn/plugins/yubikey/transport.rb +1017 -0
  124. data/lib/pwn/plugins/yubikey.rb +844 -0
  125. data/lib/pwn/plugins.rb +1 -0
  126. data/lib/pwn/redaction.rb +69 -12
  127. data/lib/pwn/session_trace.rb +4 -0
  128. data/lib/pwn/setup.rb +114 -24
  129. data/lib/pwn/version.rb +1 -1
  130. data/lib/pwn/www/app_cobalt_io.rb +16 -5
  131. data/lib/pwn/www/bug_crowd.rb +16 -5
  132. data/lib/pwn/www/coinbase_pro.rb +16 -5
  133. data/lib/pwn/www/github.rb +30 -14
  134. data/lib/pwn/www/paypal.rb +15 -4
  135. data/lib/pwn/www/synack.rb +16 -5
  136. data/lib/pwn/www/twitter.rb +17 -6
  137. data/lib/pwn.rb +1 -0
  138. data/spec/documentation/installation_md_spec.rb +1 -1
  139. data/spec/fixtures/yubikey/apdu_frames.json +162 -0
  140. data/spec/integration/authentication_helper_oath_spec.rb +117 -0
  141. data/spec/integration/controller_spill_spec.rb +48 -0
  142. data/spec/integration/harness_process_spec.rb +97 -0
  143. data/spec/integration/mission_cancel_spec.rb +79 -0
  144. data/spec/integration/mission_curses_pty_spec.rb +44 -0
  145. data/spec/integration/readme_security_spec.rb +47 -0
  146. data/spec/integration/web_cache_deception_yubikey_spec.rb +159 -0
  147. data/spec/lib/pwn/ai/agent/dispatch_spec.rb +6 -3
  148. data/spec/lib/pwn/ai/agent/dispatch_vault_safety_spec.rb +86 -0
  149. data/spec/lib/pwn/ai/agent/injection_guard_spec.rb +2 -0
  150. data/spec/lib/pwn/ai/agent/learning_spec.rb +4 -4
  151. data/spec/lib/pwn/ai/agent/loop_artifacts_spec.rb +5 -0
  152. data/spec/lib/pwn/ai/agent/loop_spec.rb +104 -29
  153. data/spec/lib/pwn/ai/agent/mission_namespace_spec.rb +138 -0
  154. data/spec/lib/pwn/ai/agent/policy_evaluation_spec.rb +10 -23
  155. data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +48 -0
  156. data/spec/lib/pwn/ai/agent/shell_search_effect_spec.rb +44 -0
  157. data/spec/lib/pwn/ai/agent/solve_spec.rb +6 -4
  158. data/spec/lib/pwn/ai/agent/tools/browser_act_spec.rb +44 -0
  159. data/spec/lib/pwn/ai/agent/tools/loot_query_yubikey_spec.rb +55 -0
  160. data/spec/lib/pwn/ai/agent/tools/recon_run_yubikey_spec.rb +43 -0
  161. data/spec/lib/pwn/ai/agent/tools/workspace_tool_spec.rb +20 -0
  162. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +7 -0
  163. data/spec/lib/pwn/ai/anthropic_vault_enrollment_spec.rb +37 -0
  164. data/spec/lib/pwn/ai/cli_spec.rb +8 -16
  165. data/spec/lib/pwn/ai/oauth_vault_persistence_spec.rb +195 -0
  166. data/spec/lib/pwn/ai/open_ai_spec.rb +2 -2
  167. data/spec/lib/pwn/ai/open_web_ui_workspace_spec.rb +36 -0
  168. data/spec/lib/pwn/ai/router_spec.rb +1 -1
  169. data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
  170. data/spec/lib/pwn/backups_spec.rb +129 -0
  171. data/spec/lib/pwn/config_redaction_yubikey_spec.rb +28 -0
  172. data/spec/lib/pwn/config_yubikey_spec.rb +92 -0
  173. data/spec/lib/pwn/migrate_spec.rb +29 -81
  174. data/spec/lib/pwn/migrate_yubikey_spec.rb +128 -0
  175. data/spec/lib/pwn/module_skills_spec.rb +8 -0
  176. data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +230 -0
  177. data/spec/lib/pwn/plugins/authentication_helper_native_yubikey_spec.rb +40 -0
  178. data/spec/lib/pwn/plugins/authentication_helper_yubikey_spec.rb +90 -0
  179. data/spec/lib/pwn/plugins/capability_broker/daemon_spec.rb +1 -1
  180. data/spec/lib/pwn/plugins/google_workspace_spec.rb +17 -35
  181. data/spec/lib/pwn/plugins/log_yubikey_spec.rb +47 -0
  182. data/spec/lib/pwn/plugins/preflight_checker_native_yubikey_spec.rb +19 -0
  183. data/spec/lib/pwn/plugins/preflight_checker_yubikey_spec.rb +55 -0
  184. data/spec/lib/pwn/plugins/recon_yubikey_spec.rb +43 -0
  185. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +110 -75
  186. data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
  187. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +19 -16
  188. data/spec/lib/pwn/plugins/repl/ai_vault_edit_yubikey_spec.rb +103 -0
  189. data/spec/lib/pwn/plugins/repl/ai_yubikey_spec.rb +48 -0
  190. data/spec/lib/pwn/plugins/repl/mesh_vault_persistence_spec.rb +149 -0
  191. data/spec/lib/pwn/plugins/repl/vault_yaml_serialization_spec.rb +11 -0
  192. data/spec/lib/pwn/plugins/repl/vault_yubikey_spec.rb +39 -0
  193. data/spec/lib/pwn/plugins/repl_mesh_layout_spec.rb +2 -0
  194. data/spec/lib/pwn/plugins/repl_spec.rb +56 -5
  195. data/spec/lib/pwn/plugins/transparent_browser_jev_spec.rb +46 -0
  196. data/spec/lib/pwn/plugins/vault_native_yubikey_spec.rb +29 -0
  197. data/spec/lib/pwn/plugins/vault_publication_safety_spec.rb +258 -0
  198. data/spec/lib/pwn/plugins/vault_store_safety_spec.rb +241 -0
  199. data/spec/lib/pwn/plugins/vault_yubikey_safety_spec.rb +168 -0
  200. data/spec/lib/pwn/plugins/vault_yubikey_spec.rb +128 -0
  201. data/spec/lib/pwn/plugins/vault_yubikey_vector_spec.rb +45 -0
  202. data/spec/lib/pwn/plugins/yubikey/fido_spec.rb +854 -0
  203. data/spec/lib/pwn/plugins/yubikey/hsmauth_spec.rb +235 -0
  204. data/spec/lib/pwn/plugins/yubikey/management_spec.rb +277 -0
  205. data/spec/lib/pwn/plugins/yubikey/oath_spec.rb +349 -0
  206. data/spec/lib/pwn/plugins/yubikey/openpgp_spec.rb +810 -0
  207. data/spec/lib/pwn/plugins/yubikey/otp_spec.rb +337 -0
  208. data/spec/lib/pwn/plugins/yubikey/piv_spec.rb +646 -0
  209. data/spec/lib/pwn/plugins/yubikey/protocol_secure_spec.rb +28 -0
  210. data/spec/lib/pwn/plugins/yubikey/protocol_spec.rb +149 -0
  211. data/spec/lib/pwn/plugins/yubikey/protocol_vectors_spec.rb +18 -0
  212. data/spec/lib/pwn/plugins/yubikey/security_domain/scp_spec.rb +256 -0
  213. data/spec/lib/pwn/plugins/yubikey/security_domain/support.rb +153 -0
  214. data/spec/lib/pwn/plugins/yubikey/security_domain_spec.rb +296 -0
  215. data/spec/lib/pwn/plugins/yubikey/transport_spec.rb +760 -0
  216. data/spec/lib/pwn/plugins/yubikey_security_spec.rb +260 -0
  217. data/spec/lib/pwn/plugins/yubikey_spec.rb +209 -0
  218. data/spec/lib/pwn/plugins/yubikey_terminal_spec.rb +147 -0
  219. data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
  220. data/spec/lib/pwn/session_cleanup_default_spec.rb +7 -5
  221. data/spec/lib/pwn/session_cleanup_payload_spec.rb +11 -11
  222. data/spec/lib/pwn/session_cleanup_recovery_spec.rb +34 -20
  223. data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
  224. data/spec/lib/pwn/setup_vault_yubikey_spec.rb +61 -0
  225. data/spec/lib/pwn/setup_yubikey_spec.rb +40 -0
  226. data/spec/lib/pwn/www/login_yubikey_spec.rb +273 -0
  227. data/spec/spec_helper.rb +15 -0
  228. data/spec/support/harness/cli_faults.rb +13 -9
  229. data/spec/support/harness/controller_spill.rb +26 -0
  230. data/spec/support/harness/controller_state.rb +54 -0
  231. data/spec/support/harness/evaluate.rb +12 -1
  232. data/spec/support/harness/experiments.rb +1 -1
  233. data/spec/support/harness/fake_provider.rb +22 -6
  234. data/spec/support/harness/fault_preload.rb +3 -1
  235. data/spec/support/harness/mission_console.rb +54 -0
  236. data/spec/support/harness/mission_curses_pty.rb +41 -0
  237. data/spec/support/harness/mission_dispatch.rb +30 -0
  238. data/spec/support/harness/mission_fixture_preload.rb +24 -0
  239. data/spec/support/harness/mission_job_lifetime.rb +25 -0
  240. data/spec/support/harness/mission_legacy.rb +25 -0
  241. data/spec/support/harness/mission_normal_exit.rb +34 -0
  242. data/spec/support/harness/mission_swarm.rb +23 -0
  243. data/spec/support/harness/mission_unsupported.rb +29 -0
  244. data/spec/support/harness/package_acceptance.rb +33 -5
  245. data/spec/support/harness/session_acceptance.rb +4 -4
  246. data/spec/support/harness/swarm_acceptance.rb +1 -1
  247. data/spec/support/harness/swarm_control.rb +44 -0
  248. data/spec/support/mission_namespace.rb +14 -0
  249. data/spec/support/mission_pipe_queue.rb +33 -0
  250. data/third_party/pwn_rdoc.jsonl +605 -19
  251. metadata +129 -16
  252. data/scripts/benchmark_policy.rb +0 -376
  253. data/scripts/build_gate.rb +0 -58
  254. data/spec/integration/build_gate_spec.rb +0 -73
@@ -100,7 +100,7 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
100
100
  controller = described_class.new(session_id: 'fixture')
101
101
  controller.execute(line: "/swarm mission #{request}")
102
102
  controller.send(:workers).each(&:join)
103
- expect(controller.snapshot[:jobs].first).to include(state: 'incomplete', team: include(roles: include(protagonist: 'build')))
103
+ expect(controller.snapshot[:jobs].first).to include(state: 'incomplete', team: include(roles: include('protagonist' => 'build')))
104
104
  ensure
105
105
  controller&.close
106
106
  end
@@ -131,20 +131,20 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
131
131
  end
132
132
 
133
133
  it 'owns model interrupts on the job thread and copies request-local routing' do
134
- entered = Queue.new
134
+ entered = PWNTestPipeQueue.new
135
135
  allow(PWN::AI::Agent::Swarm).to receive(:create).and_return(swarm_id: 'fixture')
136
136
  Thread.current[:pwn_workspace_fixture] = :copied
137
137
  allow(PWN::AI::Agent::Swarm).to receive(:ask) do |opts|
138
138
  control = opts[:steering]
139
- entered << [control.instance_variable_get(:@owner), Thread.current, Thread.current[:pwn_workspace_fixture], Thread.current[:pwn_steering_input]]
139
+ entered << [control.instance_variable_get(:@owner) == Thread.current, Thread.current[:pwn_workspace_fixture], Thread.current[:pwn_steering_input].is_a?(PWN::Plugins::REPL::AISwarm::JobControl)]
140
140
  { reply: 'done' }
141
141
  end
142
142
  controller = described_class.new(session_id: 'fixture')
143
143
  controller.execute(line: '/swarm dm scout inspect')
144
- owner, worker, local, marker = entered.pop
145
- expect(owner).to eq(worker)
146
- expect(local).to eq(:copied)
147
- expect(marker).to be_a(PWN::Plugins::REPL::AISwarm::JobControl)
144
+ owner_matches, local, marker = entered.pop
145
+ expect(owner_matches).to be(true)
146
+ expect(local).to eq('copied')
147
+ expect(marker).to be(true)
148
148
  ensure
149
149
  controller&.close
150
150
  Thread.current[:pwn_workspace_fixture] = nil
@@ -152,9 +152,9 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
152
152
 
153
153
  it 'passes prior speakers to debate, preserves literal flag-like missions, and retains failures' do
154
154
  allow(PWN::AI::Agent::Swarm).to receive(:create).and_return(swarm_id: 'fixture')
155
- calls = []
155
+ recorded = PWNTestPipeQueue.new
156
156
  allow(PWN::AI::Agent::Swarm).to receive(:ask) do |opts|
157
- calls << opts
157
+ recorded << opts.slice(:request, :name)
158
158
  raise IOError, 'fixture unavailable' if opts[:name] == 'broken'
159
159
 
160
160
  { reply: "reply from #{opts[:name]}" }
@@ -162,6 +162,8 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
162
162
  controller = described_class.new(session_id: 'fixture')
163
163
  controller.execute(line: '/swarm debate scout,critic "--literal mission"')
164
164
  controller.send(:workers).each(&:join)
165
+ calls = []
166
+ calls << recorded.pop until recorded.empty?
165
167
  expect(calls.first[:request]).to eq('--literal mission')
166
168
  expect(calls.last[:request]).to include('reply from scout', '--literal mission')
167
169
  controller.execute(line: '/swarm dm broken inspect')
@@ -172,7 +174,7 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
172
174
  end
173
175
 
174
176
  it 'cancels only its owned blocked model and cannot switch swarms while it runs' do
175
- entered = Queue.new
177
+ entered = PWNTestPipeQueue.new
176
178
  foreign = Thread.new { sleep }
177
179
  allow(PWN::AI::Agent::Swarm).to receive(:create).and_return(swarm_id: 'fixture')
178
180
  allow(PWN::AI::Agent::Swarm).to receive(:ask) do |opts|
@@ -199,12 +201,13 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
199
201
  end
200
202
 
201
203
  it 'runs, steers, and cancels a persona through the existing Swarm API' do
202
- entered = Queue.new
204
+ entered = PWNTestPipeQueue.new
203
205
  allow(PWN::AI::Agent::Swarm).to receive(:ask) do |opts|
204
- entered << opts[:steering]
206
+ entered << true
205
207
  opts[:on_tool].call('shell', { command: 'true' }, '{"success":true}')
206
- sleep 0.01 until opts[:steering].instance_variable_get(:@stopping)
207
- raise PWN::Plugins::REPL::AISwarm::JobControl::Stopped, 'cancelled'
208
+ sleep 0.01 while opts[:steering].instance_variable_get(:@pending).empty?
209
+ entered << opts[:steering].instance_variable_get(:@pending)
210
+ sleep 30
208
211
  end
209
212
  events = Queue.new
210
213
  controller = described_class.new(session_id: 'console-session')
@@ -212,11 +215,11 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
212
215
  allow(PWN::AI::Agent::Swarm).to receive(:spawn).and_return(name: 'scout')
213
216
  controller.execute(line: '/swarm spawn scout inspect evidence')
214
217
  queued = controller.execute(line: '/swarm dm scout inspect the fixture', on_event: ->(type, text) { events << [type, text] })
215
- steering = entered.pop
218
+ expect(entered.pop).to be(true)
216
219
  expect(queued[:ok]).to eq(true)
217
220
  expect(controller.snapshot[:jobs].first[:state]).to eq('running')
218
221
  controller.execute(line: "/swarm steer #{queued[:job_id]} stop the scan")
219
- expect(steering.instance_variable_get(:@pending)).to include('stop the scan')
222
+ expect(entered.pop).to include('stop the scan')
220
223
  controller.execute(line: "/swarm cancel #{queued[:job_id]}")
221
224
  sleep 0.05
222
225
  expect(controller.snapshot[:jobs].first[:state]).to eq('cancelled')
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tmpdir'
5
+ require 'stringio'
6
+
7
+ RSpec.describe PWN::Plugins::REPL::AIConsole::VaultEdit, 'hardware vault editing' do
8
+ around do |example|
9
+ Dir.mktmpdir('vault-edit-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', key: 'synthetic-private-value' } }, retained: ['fixture-value'] }
23
+ File.binwrite(@path, PWN::Plugins::Vault.encrypt_data(data: YAML.dump(@original), yubikey: { slot: 2, serial: '12345678' }))
24
+ File.chmod(0o600, @path)
25
+ stub_const('PWN::Env', @original.merge(driver_opts: { pwn_env_path: @path, pwn_dec_path: File.join(@root, 'absent-sidecar') }))
26
+ stub_const('PWN::EnvRedacted', {})
27
+ allow(Pry).to receive(:history).and_return(Pry::History.new(file_path: File.join(@root, 'history')))
28
+ end
29
+
30
+ it 'keeps malformed YAML and cancellation private and leaves hardware ciphertext untouched' do
31
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
32
+ bytes = File.binread(@path)
33
+ console.submit('/vault edit')
34
+ console.instance_variable_get(:@vault_editor).replace('synthetic-private-value: [')
35
+ console.handle("\u0013")
36
+ expect(console.instance_variable_get(:@vault_error)).to include('Invalid YAML')
37
+ expect(console.instance_variable_get(:@vault_error)).not_to include('synthetic-private-value')
38
+ console.handle("\e")
39
+ expect(console.instance_variable_get(:@vault_editor)).to be_nil
40
+ expect(console.instance_variable_get(:@timeline).inspect).not_to include('synthetic-private-value', 'Invalid YAML')
41
+ expect(Pry.history.to_a.join).not_to include('synthetic-private-value')
42
+ expect(File.binread(@path)).to eq(bytes)
43
+ end
44
+
45
+ it 'preserves hardware ciphertext and Env when encryption fails or is interrupted' do
46
+ edit = described_class.new(%w[ai ollama model])
47
+ bytes = File.binread(@path)
48
+ env = PWN::Env
49
+ allow(PWN::Plugins::Vault).to receive(:encrypt_data).and_raise(IOError, 'synthetic-private-value')
50
+ expect { edit.save('new-model') }.to raise_error(described_class::Error, /Vault not saved/)
51
+ expect(File.binread(@path)).to eq(bytes)
52
+ expect(PWN::Env).to equal(env)
53
+ allow(PWN::Plugins::Vault).to receive(:encrypt_data).and_raise(Interrupt)
54
+ expect { edit.save('new-model') }.to raise_error(Interrupt)
55
+ expect(File.binread(@path)).to eq(bytes)
56
+ expect(PWN::Env).to equal(env)
57
+ expect(Dir.children(@root)).to eq(['selected-vault'])
58
+ end
59
+
60
+ it 'merges unrelated hardware edits and refuses a changed selected scope' do
61
+ edit = described_class.new(%w[ai ollama model])
62
+ described_class.new(['retained']).save("- changed\n")
63
+ edit.save('new-model')
64
+ expect(PWN::Plugins::Vault.dump(file: @path)[:retained]).to eq(['changed'])
65
+ bytes = File.binread(@path)
66
+ expect { edit.save('stale-model') }.to raise_error(described_class::Error, /scope changed/)
67
+ expect(File.binread(@path)).to eq(bytes)
68
+ end
69
+
70
+ it 'rejects a final ciphertext conflict without leaving a staging file' do
71
+ edit = described_class.new(%w[ai ollama model])
72
+ other = PWN::Plugins::Vault.encrypt_data(data: YAML.dump(@original.merge(retained: ['concurrent'])), yubikey: { slot: 2 })
73
+ allow(edit).to receive(:atomic_write).and_wrap_original do |method, encrypted, previous|
74
+ File.binwrite(@path, other)
75
+ method.call(encrypted, previous)
76
+ end
77
+ expect { edit.save('new-model') }.to raise_error(described_class::Error, /changed during save/)
78
+ expect(File.binread(@path)).to eq(other)
79
+ expect(Dir.children(@root)).to eq(['selected-vault'])
80
+ end
81
+
82
+ it 'fails closed with a different device response and redacted open errors' do
83
+ bytes = File.binread(@path)
84
+ allow(PWN::Plugins::YubiKey).to receive(:challenge_response).and_return('x' * 20)
85
+ expect { described_class.new([]) }.to raise_error(described_class::Error, /Cannot open vault/)
86
+ expect(File.binread(@path)).to eq(bytes)
87
+ end
88
+
89
+ it 'opens and saves the selected hardware scope without requiring or creating a decryptor' do
90
+ expect(PWN::Config).not_to receive(:refresh_env)
91
+ expect(PWN::Plugins::AuthenticationHelper).not_to receive(:mask_password)
92
+ edit = described_class.new(%w[ai ollama model])
93
+ expect(edit.text).to include('fixture-model')
94
+ expect(edit.save('new-model')).to eq(:saved)
95
+ expect(PWN::Plugins::Vault.hardware_encrypted?(file: @path)).to be(true)
96
+ document = PWN::Plugins::Vault.dump(file: @path)
97
+ expect(document.dig(:ai, :ollama, :model)).to eq('new-model')
98
+ expect(document[:retained]).to eq(['fixture-value'])
99
+ expect(PWN::Env.dig(:ai, :ollama, :model)).to eq('new-model')
100
+ expect(PWN::EnvRedacted.dig(:ai, :ollama, :key)).to include('REDACTED')
101
+ expect(Dir.children(@root)).to eq(['selected-vault'])
102
+ end
103
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tmpdir'
5
+
6
+ RSpec.describe PWN::Plugins::REPL, 'hardware vault settings persistence' do
7
+ around do |example|
8
+ Dir.mktmpdir('ai-yubikey-') do |root|
9
+ @root = root
10
+ example.run
11
+ end
12
+ end
13
+
14
+ before do
15
+ allow(Dir).to receive(:home).and_return(@root)
16
+ stub_const('PWN::Plugins::YubiKey', double('synthetic YubiKey'))
17
+ allow(PWN::Plugins::YubiKey).to receive(:challenge_response) do |opts|
18
+ OpenSSL::HMAC.digest('SHA1', 'synthetic-slot-secret', opts[:challenge])
19
+ end
20
+ @path = File.join(@root, 'selected-vault')
21
+ @original = { ai: { active: 'ollama', ollama: { model: 'fixture-model', system_role_content: 'fixture-role' } }, retained: 'fixture-value' }
22
+ File.binwrite(@path, PWN::Plugins::Vault.encrypt_data(data: YAML.dump(@original), yubikey: { slot: 2 }))
23
+ stub_const('PWN::Env', @original.merge(driver_opts: { pwn_env_path: @path, pwn_dec_path: File.join(@root, 'absent-sidecar') }))
24
+ end
25
+
26
+ it 'preserves ciphertext and Env on encryption failure without logging private exception content' do
27
+ bytes = File.binread(@path)
28
+ env = Marshal.dump(PWN::Env)
29
+ allow(PWN::Plugins::Vault).to receive(:encrypt_data).and_raise(IOError, 'synthetic-private-exception')
30
+ expect do
31
+ expect(described_class.persist_ai_selection(engine: 'ollama', model: 'new-model')).to be(false)
32
+ end.to output(/settings persist failed: IOError/).to_stderr
33
+ expect(File.binread(@path)).to eq(bytes)
34
+ expect(Marshal.dump(PWN::Env)).to eq(env)
35
+ expect(Dir.children(@root)).to eq(['selected-vault'])
36
+ expect do
37
+ described_class.persist_ai_selection(engine: 'ollama', model: 'new-model')
38
+ end.not_to output(/synthetic-private-exception/).to_stderr
39
+ end
40
+
41
+ it 'persists model and effort into the hardware vault without converting its format or losing other settings' do
42
+ expect(described_class.persist_ai_selection(engine: 'ollama', model: 'new-model', reasoning_effort: 'high')).to be(true)
43
+ expect(PWN::Plugins::Vault.hardware_encrypted?(file: @path)).to be(true)
44
+ expected = { ai: { active: 'ollama', ollama: { model: 'new-model', system_role_content: 'fixture-role', reasoning_effort: 'high' } }, retained: 'fixture-value' }
45
+ expect(PWN::Plugins::Vault.dump(file: @path)).to eq(expected)
46
+ expect(Dir.children(@root)).to eq(['selected-vault'])
47
+ end
48
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tmpdir'
5
+
6
+ RSpec.describe PWN::Plugins::REPL, 'mesh vault persistence' do
7
+ before do
8
+ @home = Dir.mktmpdir('pwn-mesh-vault-')
9
+ allow(Dir).to receive(:home).and_return(@home)
10
+ @path = File.join(@home, 'custom-vault')
11
+ @decryptor = File.join(@home, 'independent-decryptor')
12
+ stub_const('PWN::Env', { driver_opts: { pwn_env_path: @path, pwn_dec_path: @decryptor }, plugins: {} })
13
+ @device = double('offline YubiKey')
14
+ stub_const('PWN::Plugins::YubiKey', @device)
15
+ allow(@device).to receive(:challenge_response) do |opts|
16
+ expect(opts).to include(slot: 2, serial: '123456')
17
+ OpenSSL::HMAC.digest('SHA1', 'synthetic-slot-secret', opts.fetch(:challenge))
18
+ end
19
+ @cfg = {
20
+ unrelated: 'keep', plugins: {
21
+ meshtastic: {
22
+ transport: 'mqtt', channel: { active: 'LongFast', LongFast: { psk: 'fixture-psk', topic: '2/e/LongFast/#' } },
23
+ mqtt: { username: 'fixture-user', password: 'fixture-password' }, serial: { port: '/dev/fixture-old', baud: 115_200 }
24
+ }
25
+ }
26
+ }
27
+ File.write(@path, YAML.dump(@cfg))
28
+ PWN::Plugins::Vault.encrypt(file: @path, yubikey: { slot: 2, serial: '123456' })
29
+ expect(PWN::Plugins::AuthenticationHelper).not_to receive(:mask_password)
30
+ allow(described_class).to receive(:mesh_ui_puts)
31
+ allow(ENV).to receive(:fetch).and_call_original
32
+ allow(ENV).to receive(:fetch).with('PWN_DECRYPTOR_KEY', nil).and_return(nil)
33
+ allow(ENV).to receive(:fetch).with('PWN_DECRYPTOR_IV', nil).and_return(nil)
34
+ end
35
+
36
+ after { FileUtils.remove_entry(@home) }
37
+
38
+ it 'merges menu settings into a hardware vault without a decryptor or plaintext rewrite' do
39
+ expect(PWN::Plugins::Vault).to receive(:update).with(file: @path, decryptor_file: @decryptor).and_call_original
40
+ expect(PWN::Plugins::Vault).not_to receive(:decrypt)
41
+ expect(PWN::Plugins::Vault).not_to receive(:encrypt)
42
+ expect(File).not_to receive(:write)
43
+ mesh = { 'transport' => 'serial', 'serial' => { 'port' => '/dev/fixture-new' }, 'channel' => { 'active' => 'LongFast' } }
44
+
45
+ expect(described_class.persist_mesh_env(mesh: mesh)).to be(true)
46
+ persisted = PWN::Plugins::Vault.dump(file: @path)
47
+ expect(persisted.dig(:plugins, :meshtastic)).to eq(@cfg.dig(:plugins, :meshtastic).merge(transport: 'serial', serial: { port: '/dev/fixture-new', baud: 115_200 }))
48
+ expect(persisted[:unrelated]).to eq('keep')
49
+ expect(File.binread(@path)).to start_with(PWN::Plugins::Vault::YUBIKEY_HEADER)
50
+ expect(File.stat(@path).mode & 0o777).to eq(0o600)
51
+ expect(Dir.children(@home)).to eq(['custom-vault'])
52
+ expect(described_class).not_to have_received(:mesh_ui_puts)
53
+ end
54
+
55
+ it 'keeps legacy credentials and nested mesh settings while staging ciphertext only' do
56
+ credentials = { key: Base64.strict_encode64('k' * 32), iv: Base64.strict_encode64('i' * 16) }
57
+ File.write(@decryptor, YAML.dump(credentials))
58
+ File.chmod(0o400, @decryptor)
59
+ File.write(@path, YAML.dump(@cfg))
60
+ PWN::Plugins::Vault.encrypt({ file: @path }.merge(credentials))
61
+ before = File.binread(@path)
62
+ sidecar_before = File.binread(@decryptor)
63
+ expect(File).not_to receive(:write)
64
+ expect(PWN::Plugins::Vault).not_to receive(:decrypt)
65
+ expect(PWN::Plugins::Vault).not_to receive(:encrypt)
66
+ expect(Tempfile).to receive(:create).and_wrap_original do |create, *args, &block|
67
+ create.call(*args) do |temp|
68
+ expect(temp).to receive(:write).and_wrap_original do |write, bytes|
69
+ plaintext = PWN::Plugins::Vault.decrypt_data(credentials.merge(data: bytes))
70
+ expect(YAML.safe_load(plaintext, permitted_classes: [Symbol], symbolize_names: true).dig(:plugins, :meshtastic, :serial)).to eq(port: '/dev/fixture-new', baud: 115_200)
71
+ expect(bytes).not_to include('fixture-password', 'fixture-psk')
72
+ expect(File.binread(@path)).to eq(before)
73
+ expect(temp.stat.mode & 0o777).to eq(0o600)
74
+ write.call(bytes)
75
+ end
76
+ block.call(temp)
77
+ end
78
+ end
79
+
80
+ expect(described_class.persist_mesh_env(mesh: { serial: { port: '/dev/fixture-new' } })).to be(true)
81
+ persisted = PWN::Plugins::Vault.dump({ file: @path }.merge(credentials))
82
+ expect(persisted.dig(:plugins, :meshtastic)).to eq(@cfg.dig(:plugins, :meshtastic).merge(serial: { port: '/dev/fixture-new', baud: 115_200 }))
83
+ expect(persisted[:unrelated]).to eq('keep')
84
+ expect(File.binread(@decryptor)).to eq(sidecar_before)
85
+ expect(File.stat(@decryptor).mode & 0o777).to eq(0o400)
86
+ expect(File.stat(@path).mode & 0o777).to eq(0o600)
87
+ expect(Dir.children(@home)).to match_array(%w[custom-vault independent-decryptor])
88
+ end
89
+
90
+ it 'uses the default vault and live mesh settings when no overrides are supplied' do
91
+ default_path = File.join(@home, '.pwn', 'pwn.yaml')
92
+ FileUtils.mkdir_p(File.dirname(default_path))
93
+ FileUtils.cp(@path, default_path)
94
+ before = File.binread(@path)
95
+ PWN::Env[:driver_opts] = {}
96
+ PWN::Env[:plugins][:meshtastic] = { transport: 'serial' }
97
+
98
+ expect(described_class.persist_mesh_env).to be(true)
99
+ expect(PWN::Plugins::Vault.dump(file: default_path).dig(:plugins, :meshtastic)).to eq(@cfg.dig(:plugins, :meshtastic).merge(transport: 'serial'))
100
+ expect(File.binread(@path)).to eq(before)
101
+ expect(Dir.children(File.dirname(default_path))).to eq(['pwn.yaml'])
102
+ end
103
+
104
+ it 'does not overwrite a concurrent ciphertext change' do
105
+ concurrent = PWN::Plugins::Vault.encrypt_data(data: YAML.dump(@cfg.merge(unrelated: 'newer')), yubikey: { slot: 2, serial: '123456' })
106
+ allow(PWN::Plugins::Vault).to receive(:encrypt_data).and_wrap_original do |encrypt, opts|
107
+ ciphertext = encrypt.call(opts)
108
+ File.binwrite(@path, concurrent)
109
+ ciphertext
110
+ end
111
+
112
+ expect(described_class.persist_mesh_env(mesh: { transport: 'serial' })).to be(false)
113
+ expect(File.binread(@path)).to eq(concurrent)
114
+ expect(Dir.children(@home)).to eq(['custom-vault'])
115
+ expect(described_class).to have_received(:mesh_ui_puts).with(text: '[pwn-mesh] persist skipped: IOError')
116
+ end
117
+
118
+ it 'does not use a software sidecar when the hardware device is unavailable' do
119
+ File.write(@decryptor, YAML.dump(key: Base64.strict_encode64('k' * 32), iv: Base64.strict_encode64('i' * 16)))
120
+ before = File.binread(@path)
121
+ sidecar_before = File.binread(@decryptor)
122
+ allow(@device).to receive(:challenge_response).and_raise(IOError, 'synthetic-private-error')
123
+ expect(PWN::Plugins::Vault).not_to receive(:encrypt_data)
124
+
125
+ expect(described_class.persist_mesh_env(mesh: { transport: 'serial' })).to be(false)
126
+ expect(File.binread(@path)).to eq(before)
127
+ expect(File.binread(@decryptor)).to eq(sidecar_before)
128
+ expect(described_class).to have_received(:mesh_ui_puts).with(text: '[pwn-mesh] persist skipped: IOError')
129
+ end
130
+
131
+ it 'leaves hardware ciphertext untouched when the device disappears during re-encryption' do
132
+ before = File.binread(@path)
133
+ calls = 0
134
+ allow(@device).to receive(:challenge_response) do |opts|
135
+ calls += 1
136
+ raise IOError, 'synthetic-private-error' if calls > 1
137
+
138
+ OpenSSL::HMAC.digest('SHA1', 'synthetic-slot-secret', opts.fetch(:challenge))
139
+ end
140
+ expect(PWN::Plugins::Vault).not_to receive(:decrypt)
141
+ expect(File).not_to receive(:write)
142
+ expect(Tempfile).not_to receive(:create)
143
+
144
+ expect(described_class.persist_mesh_env(mesh: { transport: 'serial' })).to be(false)
145
+ expect(described_class).to have_received(:mesh_ui_puts).with(text: '[pwn-mesh] persist skipped: IOError')
146
+ expect(File.binread(@path)).to eq(before)
147
+ expect(Dir.children(@home)).to eq(['custom-vault'])
148
+ end
149
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe PWN::Plugins::REPL::AIConsole::VaultEdit, 'YAML scalar fidelity' do
6
+ it 'preserves colon-prefixed lines inside nested mapping and array values' do
7
+ value = { setting: "before\n:leading-colon\nafter\n", rows: [{ text: "line\n:other: value\n" }] }
8
+ editor = described_class.allocate
9
+ expect(editor.parse(editor.serialize(value))).to eq(value)
10
+ end
11
+ end
@@ -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
@@ -118,6 +118,8 @@ describe PWN::Plugins::REPL, 'mesh terminal layout' do
118
118
  allow(Curses).to receive(:stdscr).and_return(double('stdscr', erase: nil, noutrefresh: nil))
119
119
  allow(Curses).to receive(:doupdate)
120
120
  expect { described_class.send(:mesh_relayout!, lines: 24, cols: 100) }.not_to raise_error
121
+ expect(PWN.const_defined?(:MeshSndrWin)).to be(false)
122
+ expect(PWN.const_defined?(:MeshRcvrWin)).to be(false)
121
123
  expect(PWN::MeshTxWin).to equal(old)
122
124
  expect(old).not_to have_received(:close)
123
125
  ensure
@@ -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