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,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'timeout'
5
+
6
+ describe 'regex-ReAct mission containment' do
7
+ include_context 'pwn tmp sandbox'
8
+
9
+ let(:repl) { PWN::Plugins::REPL }
10
+ let(:request) { +'offline legacy request' }
11
+ let(:pry) { double('pry', config: Pry::Config.new, input: double('input', line_buffer: request)) }
12
+
13
+ before do
14
+ allow(Dir).to receive(:home).and_return(@tmp)
15
+ @work = File.join(@tmp, 'work')
16
+ FileUtils.mkdir_p(@work)
17
+ allow(repl).to receive(:ready_tty!)
18
+ allow(PWN::Memory).to receive(:to_context).and_return('')
19
+ PWN::Env[:ai] = { active: :ollama, agent: { native_tools: false }, ollama: { response_history: { id: 'before' } } }
20
+ pry.config.pwn_ai = true
21
+ pry.config.pwn_ai_agent = true
22
+ allow(Pry.config).to receive(:hooks).and_return(Pry::Hooks.new)
23
+ repl.add_hooks
24
+ end
25
+
26
+ def submit
27
+ Pry.config.hooks.get_hook(:after_read, :pwn_ai_hook).call(request, pry)
28
+ end
29
+
30
+ it 'fails closed before calling a provider when mission containment is unavailable' do
31
+ allow(PWN::Plugins::AISandbox::MissionProcess).to receive(:new).and_raise(PWN::Plugins::AISandbox::NotStarted, 'enable user/PID namespaces')
32
+ expect(PWN::AI::Ollama).not_to receive(:chat)
33
+ expect { submit }.to output(%r{enable user/PID namespaces.*no uncontained retry}).to_stderr
34
+ expect(request).to eq('nil')
35
+ expect(PWN::Env.dig(:ai, :ollama, :response_history)).to eq(id: 'before')
36
+ expect(repl).to have_received(:ready_tty!)
37
+ end
38
+
39
+ it 'does not enter regex fallback after a failed native mission' do
40
+ PWN::Env[:ai][:agent][:native_tools] = true
41
+ allow(repl).to receive(:pwn_ai_run_steerable).and_raise(IOError, 'mission unavailable')
42
+ expect(repl).not_to receive(:pwn_ai_run_legacy)
43
+ expect(PWN::AI::Ollama).not_to receive(:chat)
44
+ expect { submit }.to output(/native agent loop failed/).to_stderr
45
+ expect(request).to eq('nil')
46
+ end
47
+
48
+ it 'does not retry a failed legacy provider in the controller or replace committed history' do
49
+ marker = File.join(@tmp, 'provider-calls')
50
+ allow(PWN::AI::Ollama).to receive(:chat) do
51
+ File.open(marker, 'a') { |file| file.puts(File.read('/proc/self/status')[/^NSpid:.*$/]) }
52
+ raise IOError, 'offline provider failed'
53
+ end
54
+ expect { submit }.to output(/offline provider failed.*no uncontained retry/).to_stderr
55
+ expect(File.readlines(marker).length).to eq(1)
56
+ expect(File.read(marker).split.length).to be > 2
57
+ expect(PWN::Env.dig(:ai, :ollama, :response_history)).to eq(id: 'before')
58
+ expect(request).to eq('nil')
59
+ expect(repl).to have_received(:ready_tty!)
60
+ end
61
+
62
+ it 'runs the fake provider and Ruby block in a real mission and returns history to the controller' do
63
+ marker = File.join(PWN::Plugins::AISandbox.workspace, 'tool-owner')
64
+ diagnostic = File.join(@tmp, 'provider-observation')
65
+ calls = 0
66
+ allow(PWN::AI::Ollama).to receive(:chat) do |options|
67
+ calls += 1
68
+ File.write(diagnostic, options[:request].to_s) if calls > 1
69
+ text = calls == 1 ? "```ruby\nFile.write(#{marker.inspect}, File.read('/proc/self/status'))\n```" : 'offline complete'
70
+ { id: 'after', choices: [{ content: text }] }
71
+ end
72
+ submit
73
+ expect(File).to exist(marker), File.read(diagnostic)
74
+ expect(File.read(marker)[/^NSpid:.*$/].split.length).to be > 2
75
+ expect(calls).to eq(0)
76
+ expect(PWN::Env.dig(:ai, :ollama, :response_history, :id)).to eq('after')
77
+ expect(request).to eq('nil')
78
+ end
79
+
80
+ %w[ruby bash].each do |language|
81
+ it "denies protected source writes through the actual legacy #{language} hook" do
82
+ source = File.join(@tmp, 'synthetic-source')
83
+ FileUtils.mkdir_p(source)
84
+ target = File.join(source, 'forbidden.txt')
85
+ original = PWN::Plugins::AISandbox.protected_roots
86
+ allow(PWN::Plugins::AISandbox).to receive(:protected_roots).and_return([source, *original])
87
+ code = language == 'ruby' ? "File.write(#{target.inspect}, 'forbidden')" : "printf forbidden > #{target.shellescape}"
88
+ calls = 0
89
+ allow(PWN::AI::Ollama).to receive(:chat) do
90
+ calls += 1
91
+ { choices: [{ content: calls == 1 ? "```#{language}\n#{code}\n```" : 'completed' }] }
92
+ end
93
+ submit
94
+ expect(File).not_to exist(target)
95
+ expect(request).to eq('nil')
96
+ end
97
+ end
98
+
99
+ it 'cancels regex-ReAct with a real PTY CTRL+C and completes the next submitted mission' do
100
+ require 'pty'
101
+ script = File.join(@tmp, 'regex_pty.rb')
102
+ File.write(script, <<~'RUBY')
103
+ require 'pwn'
104
+ require 'pry'
105
+ require 'ostruct'
106
+ require 'fileutils'
107
+ $stdout.sync = true
108
+ work = ARGV.fetch(0)
109
+ runtime = File.join(Dir.home, '.pwn', 'workspaces', 'runtime')
110
+ FileUtils.mkdir_p(runtime)
111
+ PWN::Env[:ai] = { active: :ollama, ollama: {} }
112
+ PWN::Memory.define_singleton_method(:to_context) { |*| '' }
113
+ PWN::AI::Ollama.define_singleton_method(:chat) do |opts|
114
+ if opts[:request] == 'second'
115
+ { id: 'second', choices: [{ content: 'SECOND COMPLETE' }] }
116
+ else
117
+ payload = "Process.setsid; File.write(#{File.join(runtime, 'pty-ready').inspect}, File.read('/proc/self/status')); sleep 1; File.write(#{File.join(runtime, 'pty-late').inspect}, 'late'); sleep 30"
118
+ code = "Process.spawn(#{RbConfig.ruby.inspect}, '-e', #{payload.inspect}, out: File::NULL, err: File::NULL); sleep 30"
119
+ { choices: [{ content: "```ruby\n#{code}\n```" }] }
120
+ end
121
+ end
122
+ pi = OpenStruct.new(config: OpenStruct.new(pwn_ai_agent: true))
123
+ begin
124
+ PWN::Plugins::REPL.pwn_ai_run_legacy(request: +'first', original_request: 'first', pry: pi)
125
+ rescue Interrupt
126
+ puts 'CANCELLED READY'
127
+ end
128
+ next_request = $stdin.gets.to_s.strip
129
+ PWN::Plugins::REPL.pwn_ai_run_legacy(request: next_request.dup, original_request: next_request, pry: pi)
130
+ puts "HISTORY #{PWN::Env.dig(:ai, :ollama, :response_history, :id)}"
131
+ RUBY
132
+ output = +''
133
+ reader, writer, pid = PTY.spawn({ 'HOME' => @tmp }, RbConfig.ruby, '-I', File.expand_path('../../../../../lib', __dir__), script, @work)
134
+ drain = Thread.new do
135
+ loop { output << reader.readpartial(4096) }
136
+ rescue EOFError, Errno::EIO
137
+ nil
138
+ end
139
+ marker = File.join(@tmp, '.pwn', 'workspaces', 'runtime', 'pty-ready')
140
+ Timeout.timeout(10) { sleep 0.01 until File.exist?(marker) }
141
+ host_pid = File.read(marker)[/^NSpid:\s+(\d+)/, 1].to_i
142
+ writer.write("\x03")
143
+ Timeout.timeout(10) { sleep 0.01 until output.include?('CANCELLED READY') }
144
+ expect { Process.kill(0, host_pid) }.to raise_error(Errno::ESRCH)
145
+ writer.write("second\n")
146
+ Timeout.timeout(10) { Process.wait(pid) }
147
+ pid = nil
148
+ drain.join(5)
149
+ expect(output).to include('SECOND COMPLETE', 'HISTORY second')
150
+ sleep 1.1
151
+ expect(File).not_to exist(File.join(@tmp, '.pwn', 'workspaces', 'runtime', 'pty-late'))
152
+ ensure
153
+ if pid
154
+ begin
155
+ Process.kill('KILL', pid)
156
+ rescue StandardError
157
+ nil
158
+ end
159
+ begin
160
+ Process.wait(pid)
161
+ rescue StandardError
162
+ nil
163
+ end
164
+ end
165
+ [reader, writer].compact.each { |io| io.close unless io.closed? }
166
+ drain&.join(5)
167
+ end
168
+
169
+ %w[ruby bash].each do |language|
170
+ it "kills a detached #{language} descendant on interrupt before ready, then accepts another request" do
171
+ marker = File.join(PWN::Plugins::AISandbox.workspace, 'detached')
172
+ late = File.join(PWN::Plugins::AISandbox.workspace, 'late')
173
+ payload = "Process.setsid; File.write(#{marker.inspect}, File.read('/proc/self/status')); sleep 1; File.write(#{late.inspect}, 'escaped'); sleep 30"
174
+ ruby_code = "Process.spawn(#{RbConfig.ruby.inspect}, '-e', #{payload.inspect}, out: File::NULL, err: File::NULL); sleep 30"
175
+ code = language == 'ruby' ? ruby_code : [RbConfig.ruby, '-e', ruby_code].shelljoin
176
+ allow(PWN::AI::Ollama).to receive(:chat).and_return(choices: [{ content: "```#{language}\n#{code}\n```" }])
177
+ worker = Thread.new do
178
+ submit
179
+ rescue Interrupt
180
+ :cancelled
181
+ end
182
+ Timeout.timeout(10) { sleep 0.01 until File.exist?(marker) }
183
+ host_pid = File.read(marker)[/^NSpid:\s+(\d+)/, 1].to_i
184
+ worker.raise(Interrupt)
185
+ expect(Timeout.timeout(10) { worker.value }).to eq(:cancelled)
186
+ expect(repl).to have_received(:ready_tty!)
187
+ expect { Process.kill(0, host_pid) }.to raise_error(Errno::ESRCH)
188
+ sleep 1.1
189
+ expect(File).not_to exist(late)
190
+ allow(PWN::AI::Ollama).to receive(:chat).and_return(id: 'second', choices: [{ content: 'second complete' }])
191
+ request.replace('second request')
192
+ submit
193
+ expect(PWN::Env.dig(:ai, :ollama, :response_history, :id)).to eq('second')
194
+ expect(request).to eq('nil')
195
+ ensure
196
+ worker&.raise(Interrupt) if worker&.alive?
197
+ worker&.join(10)
198
+ end
199
+ end
200
+ end
@@ -5,6 +5,17 @@ require 'spec_helper'
5
5
  describe PWN::Plugins::REPL::AISwarm::Controller do
6
6
  include_context 'pwn tmp sandbox'
7
7
 
8
+ it 'routes explicit continuation retirement through the slash command without changing ordinary retire' do
9
+ controller = described_class.new(session_id: 'fixture')
10
+ allow(PWN::AI::Agent::Swarm).to receive(:create).and_return(swarm_id: 'fixture-swarm')
11
+ expect(PWN::AI::Agent::Swarm).to receive(:retire).with(hash_including(name: 'scout', continuation: true)).and_return(retired: true)
12
+ expect(PWN::AI::Agent::Swarm).to receive(:retire).with(hash_including(name: 'critic', continuation: false)).and_return(retired: true)
13
+ controller.execute(line: '/swarm retire scout --continuation')
14
+ controller.execute(line: '/swarm retire critic')
15
+ ensure
16
+ controller&.close
17
+ end
18
+
8
19
  it 'retains cancellation evidence and forwards bounded mission options unchanged' do
9
20
  allow(PWN::AI::Agent::Swarm).to receive(:solve) do |opts|
10
21
  expect(opts).to include(worktree: true, rounds: 2, verification_timeout: 120, role_timeout: 45)
@@ -89,7 +100,7 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
89
100
  controller = described_class.new(session_id: 'fixture')
90
101
  controller.execute(line: "/swarm mission #{request}")
91
102
  controller.send(:workers).each(&:join)
92
- 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')))
93
104
  ensure
94
105
  controller&.close
95
106
  end
@@ -120,20 +131,20 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
120
131
  end
121
132
 
122
133
  it 'owns model interrupts on the job thread and copies request-local routing' do
123
- entered = Queue.new
134
+ entered = PWNTestPipeQueue.new
124
135
  allow(PWN::AI::Agent::Swarm).to receive(:create).and_return(swarm_id: 'fixture')
125
136
  Thread.current[:pwn_workspace_fixture] = :copied
126
137
  allow(PWN::AI::Agent::Swarm).to receive(:ask) do |opts|
127
138
  control = opts[:steering]
128
- 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)]
129
140
  { reply: 'done' }
130
141
  end
131
142
  controller = described_class.new(session_id: 'fixture')
132
143
  controller.execute(line: '/swarm dm scout inspect')
133
- owner, worker, local, marker = entered.pop
134
- expect(owner).to eq(worker)
135
- expect(local).to eq(:copied)
136
- 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)
137
148
  ensure
138
149
  controller&.close
139
150
  Thread.current[:pwn_workspace_fixture] = nil
@@ -141,9 +152,9 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
141
152
 
142
153
  it 'passes prior speakers to debate, preserves literal flag-like missions, and retains failures' do
143
154
  allow(PWN::AI::Agent::Swarm).to receive(:create).and_return(swarm_id: 'fixture')
144
- calls = []
155
+ recorded = PWNTestPipeQueue.new
145
156
  allow(PWN::AI::Agent::Swarm).to receive(:ask) do |opts|
146
- calls << opts
157
+ recorded << opts.slice(:request, :name)
147
158
  raise IOError, 'fixture unavailable' if opts[:name] == 'broken'
148
159
 
149
160
  { reply: "reply from #{opts[:name]}" }
@@ -151,6 +162,8 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
151
162
  controller = described_class.new(session_id: 'fixture')
152
163
  controller.execute(line: '/swarm debate scout,critic "--literal mission"')
153
164
  controller.send(:workers).each(&:join)
165
+ calls = []
166
+ calls << recorded.pop until recorded.empty?
154
167
  expect(calls.first[:request]).to eq('--literal mission')
155
168
  expect(calls.last[:request]).to include('reply from scout', '--literal mission')
156
169
  controller.execute(line: '/swarm dm broken inspect')
@@ -161,7 +174,7 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
161
174
  end
162
175
 
163
176
  it 'cancels only its owned blocked model and cannot switch swarms while it runs' do
164
- entered = Queue.new
177
+ entered = PWNTestPipeQueue.new
165
178
  foreign = Thread.new { sleep }
166
179
  allow(PWN::AI::Agent::Swarm).to receive(:create).and_return(swarm_id: 'fixture')
167
180
  allow(PWN::AI::Agent::Swarm).to receive(:ask) do |opts|
@@ -188,12 +201,13 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
188
201
  end
189
202
 
190
203
  it 'runs, steers, and cancels a persona through the existing Swarm API' do
191
- entered = Queue.new
204
+ entered = PWNTestPipeQueue.new
192
205
  allow(PWN::AI::Agent::Swarm).to receive(:ask) do |opts|
193
- entered << opts[:steering]
206
+ entered << true
194
207
  opts[:on_tool].call('shell', { command: 'true' }, '{"success":true}')
195
- sleep 0.01 until opts[:steering].instance_variable_get(:@stopping)
196
- 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
197
211
  end
198
212
  events = Queue.new
199
213
  controller = described_class.new(session_id: 'console-session')
@@ -201,11 +215,11 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
201
215
  allow(PWN::AI::Agent::Swarm).to receive(:spawn).and_return(name: 'scout')
202
216
  controller.execute(line: '/swarm spawn scout inspect evidence')
203
217
  queued = controller.execute(line: '/swarm dm scout inspect the fixture', on_event: ->(type, text) { events << [type, text] })
204
- steering = entered.pop
218
+ expect(entered.pop).to be(true)
205
219
  expect(queued[:ok]).to eq(true)
206
220
  expect(controller.snapshot[:jobs].first[:state]).to eq('running')
207
221
  controller.execute(line: "/swarm steer #{queued[:job_id]} stop the scan")
208
- expect(steering.instance_variable_get(:@pending)).to include('stop the scan')
222
+ expect(entered.pop).to include('stop the scan')
209
223
  controller.execute(line: "/swarm cancel #{queued[:job_id]}")
210
224
  sleep 0.05
211
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