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,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tmpdir'
5
+
6
+ RSpec.describe PWN::Plugins::Log, 'hardware credential logging' do
7
+ it 'masks sensitive trace keys before the general credential digest path' do
8
+ fields = %i[pin puk access_code management_key oath_secret static_password scp_keys mfa_code]
9
+ fields.each do |key|
10
+ expect(described_class.send(:inspect_debug, key: key, value: '123456')).to eq('[REDACTED]')
11
+ expect(described_class.send(:inspect_debug, key: key, value: '654321')).to eq('[REDACTED]')
12
+ end
13
+ expect(described_class.send(:inspect_debug, key: :api_key, value: 'fixture-api')).to eq(PWN::Redaction.token(kind: 'credential', value: 'fixture-api'))
14
+ end
15
+
16
+ it 'redacts nested structured debug values without consulting the credential store' do
17
+ expect(PWN::Plugins::Vault).not_to receive(:redact)
18
+ expect(PWN::Plugins::Vault).not_to receive(:expand)
19
+ expect(PWN::Plugins::Vault).not_to receive(:fetch)
20
+ input = { selector: { slot: 2, serial: '12345678' }, nested: [{ pin: '123456', scp_keys: %w[fixture-enc fixture-mac] }] }
21
+ text = described_class.send(:inspect_debug, value: input)
22
+ expect(text).to include('slot: 2', 'serial: "12345678"', '[REDACTED]')
23
+ expect(text).not_to include('pin: "123456"', 'fixture-enc', 'fixture-mac', '[REDACTED:credential:')
24
+ expect(input[:nested].first[:pin]).to eq('123456')
25
+ end
26
+
27
+ it 'writes only constant masks for PIN and management fields to log, debug, mirror and stderr sinks' do
28
+ Dir.mktmpdir('pwn-log-yubikey-') do |dir|
29
+ allow(Dir).to receive(:home).and_return(dir)
30
+ fixture = 'pin=123456 management_key=fixture-management mfa_code=654321'
31
+ described_class.append(level: :info, msg: { pin: '123456', management_key: 'fixture-management' })
32
+ path = File.join(dir, 'debug.log')
33
+ described_class.start_debug(path: path)
34
+ described_class.progress(msg: fixture)
35
+ described_class.mirror_tui!(msg: fixture)
36
+ described_class.capture_stderr!(text: "#{fixture}\n")
37
+ described_class.stop_debug
38
+ [path, File.join(dir, '.pwn', 'logs', 'pwn.log')].each do |file|
39
+ body = File.read(file)
40
+ expect(body).to include('[REDACTED]')
41
+ expect(body).not_to include('123456', '654321', 'fixture-management', '[REDACTED:')
42
+ end
43
+ ensure
44
+ described_class.stop_debug
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe PWN::Plugins::PreflightChecker, 'native YubiKey facade contract' do
6
+ it 'routes per-application host-only probes through the real facade without opening or enumerating a device' do
7
+ device = PWN::Plugins::YubiKey
8
+ transport = device::Transport
9
+ allow(transport).to receive(:available?).with({ application: :fido }).and_return(true)
10
+ allow(transport).to receive(:available?).with({ application: :oath }).and_return(true)
11
+ expect(transport).not_to receive(:devices)
12
+ expect(transport).not_to receive(:open)
13
+ %i[devices info command challenge_response oath_code].each { |method| expect(device).not_to receive(method) }
14
+ row = described_class.check(names: ['PWN::Plugins::YubiKey']).first
15
+ expect(row).to include(status: :ok, missing_bins: [], missing_native: [])
16
+ expect(transport).to have_received(:available?).with({ application: :fido }).once
17
+ expect(transport).to have_received(:available?).with({ application: :oath }).once
18
+ end
19
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe PWN::Plugins::PreflightChecker, 'YubiKey dependency reporting' do
6
+ let(:names) { ['PWN::Plugins::YubiKey'] }
7
+
8
+ before do
9
+ @device = double('unopened YubiKey')
10
+ stub_const('PWN::Plugins::YubiKey', @device)
11
+ %i[devices info command challenge_response oath_code].each { |method| expect(@device).not_to receive(method) }
12
+ allow(@device).to receive(:available?).with({ application: :fido }).and_return(false)
13
+ allow(@device).to receive(:available?).with({ application: :oath }).and_return(false)
14
+ allow(described_class).to receive(:bin?).and_return(true)
15
+ expect(described_class).not_to receive(:bin?).with(name: 'ykman')
16
+ end
17
+
18
+ it 'reports unavailable native host libraries without a binary requirement or device probe' do
19
+ row = described_class.check(names: names).first
20
+ expect(row).to include(plugin: names.first, status: :dead, missing_bins: [], missing_caps: [], missing_services: [])
21
+ expect(row[:missing_native]).to eq(['FIDO native transport', 'PC/SC native transport'])
22
+ expect(described_class.host_summary(names: names)).to include('PC/SC native transport', 'documentation/Plugin-Degradation.md#pwn-plugins-yubikey')
23
+ end
24
+
25
+ it 'reports partial native host capability separately from all libraries being available' do
26
+ allow(@device).to receive(:available?).with({ application: :fido }).and_return(true)
27
+ expect(described_class.check(names: names).first).to include(status: :degraded, missing_native: ['PC/SC native transport'])
28
+ allow(@device).to receive(:available?).with({ application: :oath }).and_return(true)
29
+ expect(described_class.check(names: names).first).to include(status: :ok, missing_bins: [], missing_native: [])
30
+ end
31
+
32
+ it 'preserves rows for plugins without declared native dependencies' do
33
+ expected = { plugin: 'PWN::Plugins::NmapIt', status: :ok, missing_bins: [], missing_caps: [], missing_services: [] }
34
+ expect(described_class.check(names: ['PWN::Plugins::NmapIt'])).to eq([expected])
35
+ end
36
+
37
+ it 'includes native dependency status in the manifest without changing other plugin rows' do
38
+ Dir.mktmpdir('native-preflight-') do |root|
39
+ allow(Dir).to receive(:home).and_return(root)
40
+ result = described_class.manifest
41
+ expect(result[:plugins].fetch(names.first)).to include(ok: false, missing: [], missing_native: ['FIDO native transport', 'PC/SC native transport'])
42
+ expect(result[:plugins].fetch('PWN::Plugins::NmapIt')).to eq(bins: ['nmap'], missing: [], ok: true, caps: [])
43
+ stored = JSON.parse(File.read(result.fetch(:path)))
44
+ expect(stored.fetch('plugins').fetch(names.first).fetch('missing_native')).to eq(result[:plugins][names.first][:missing_native])
45
+ end
46
+ end
47
+
48
+ it 'treats load or dependency errors as a missing host library without exposing backend messages' do
49
+ [LoadError, IOError].each do |error|
50
+ allow(@device).to receive(:available?).with({ application: :oath }).and_raise(error, 'private fixture path')
51
+ expect(described_class.check(names: names).first).to include(status: :dead, missing_native: ['FIDO native transport', 'PC/SC native transport'])
52
+ expect(described_class.host_summary(names: names)).not_to include('private fixture path')
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tmpdir'
5
+
6
+ RSpec.describe PWN::Plugins::Recon, 'hardware loot capture' do
7
+ it 'keeps hardware failures visible without creating software loot or a master key' do
8
+ Dir.mktmpdir('pwn-recon-yubikey-failure-') do |root|
9
+ allow(Dir).to receive(:home).and_return(root)
10
+ device = double('offline unavailable YubiKey')
11
+ stub_const('PWN::Plugins::YubiKey', device)
12
+ allow(device).to receive(:challenge_response).and_raise(IOError, 'fixture device unavailable')
13
+ assets = [{ address: 'app.example.test', observations: [{ banner: 'password=fixture-password' }] }]
14
+ expect { described_class.harvest_loot(assets: assets, engagement_id: 'lab', yubikey: { slot: 2 }) }.to raise_error(IOError, 'fixture device unavailable')
15
+ expect(File).not_to exist(File.join(root, '.pwn-vault.key'))
16
+ expect(PWN::Plugins::Vault.query(engagement: 'lab')).to be_empty
17
+ end
18
+ end
19
+
20
+ it 'forwards the explicit selector through run and harvest into real hardware-encrypted loot' do
21
+ Dir.mktmpdir('pwn-recon-yubikey-') do |root|
22
+ allow(Dir).to receive(:home).and_return(root)
23
+ allow(PWN::AI::Agent::Mission).to receive(:active_id).and_return(nil)
24
+ device = double('offline YubiKey fixture')
25
+ stub_const('PWN::Plugins::YubiKey', device)
26
+ selector = { slot: 2, serial: '12345678', timeout: 4, device_path: '/fixture/native-device' }
27
+ allow(device).to receive(:challenge_response) do |opts|
28
+ expect(opts).to include(selector)
29
+ OpenSSL::HMAC.digest('SHA1', 'synthetic-slot-secret', opts.fetch(:challenge))
30
+ end
31
+ socket = double('offline banner', readpartial: "username=fixture-user\npassword=fixture-password\n", remote_address: double(ip_address: '192.0.2.10'))
32
+ allow(Socket).to receive(:tcp).and_yield(socket)
33
+ result = described_class.run(target: 'app.example.test', modules: ['banner'], ports: [22], engagement_id: 'lab', root: root, yubikey: selector)
34
+ expect(File).not_to exist(File.join(root, '.pwn-vault.key'))
35
+ expect(device).to have_received(:challenge_response).once
36
+ rows = JSON.parse(File.read(File.join(root, '.pwn', 'engagements', 'lab', 'loot.json'))).fetch('records')
37
+ expect(rows.first.dig('secret', 'kdf')).to eq('yubikey-hmac-sha1-hkdf-sha256')
38
+ expect(rows.first.dig('secret', 'yubikey')).to include('slot' => 2, 'serial' => '12345678')
39
+ expect(result[:loot].first).to include(engagement: 'lab', source: 'recon', where: 'banner:22', host: '192.0.2.10')
40
+ expect(PWN::Plugins::Vault.fetch(label: result[:loot].first[:label], engagement: 'lab', yubikey: selector)).to eq('fixture-password')
41
+ end
42
+ end
43
+ end
@@ -4,6 +4,35 @@ require 'spec_helper'
4
4
  require 'stringio'
5
5
  require 'pry'
6
6
 
7
+ describe 'mission configuration handoff' do
8
+ include_context 'pwn tmp sandbox'
9
+
10
+ it 'forwards the operator trace setting to console and line-mode mission loops' do
11
+ input, sender = IO.pipe
12
+ pry = Pry.new
13
+ pry.config.pwn_ai_trace = true
14
+ pry.config.pwn_ai_session_id = 'trace-handoff'
15
+ allow(PWN::Sessions).to receive(:provisional_title).and_return(nil)
16
+ allow(PWN::Sessions).to receive(:refresh_title).and_return(nil)
17
+ allow(PWN::AI::Agent::Loop).to receive(:run) { |opts| opts[:trace] ? 'trace forwarded' : 'trace missing' }
18
+ controller = PWN::Plugins::REPL::AIConsole::Console.new(pry: pry, input: input, curses: nil, getch: nil)
19
+ Dir.chdir(@tmp) do
20
+ controller.start_request('fixture')
21
+ Timeout.timeout(10) do
22
+ while controller.busy?
23
+ controller.drain
24
+ sleep 0.01
25
+ end
26
+ end
27
+ expect(controller.instance_variable_get(:@timeline).map { |row| row[1] }).to include('trace forwarded')
28
+ expect(PWN::Plugins::REPL.pwn_ai_run_steerable(request: 'fixture', pry: pry, input: input)).to eq('trace forwarded')
29
+ end
30
+ ensure
31
+ controller&.stop_worker
32
+ [input, sender].compact.each { |io| io.close unless io.closed? }
33
+ end
34
+ end
35
+
7
36
  describe 'operator workspace navigation' do
8
37
  include_context 'pwn tmp sandbox'
9
38
 
@@ -276,16 +305,16 @@ describe 'session maintenance command workers' do
276
305
 
277
306
  after { console.send(:stop_worker) }
278
307
 
279
- it 'routes cleanup off the input owner and delivers completion after safe cancellation' do
308
+ it 'routes cleanup off the input owner and hard cancels before returning ready' do
280
309
  require File.expand_path('../../../../../etc/default_cron/session_cleanup', __dir__)
281
- entered = Queue.new
282
- release = Queue.new
283
- owner = Thread.current
310
+ entered = PWNTestPipeQueue.new
311
+ release = PWNTestPipeQueue.new
312
+ owner = Process.pid
284
313
  reader, writer = IO.pipe
285
314
  console.instance_variable_set(:@input, reader)
286
315
  console.instance_variable_set(:@tool_input, writer)
287
316
  allow(PWN::SessionCleanup).to receive(:maintain) do |**options|
288
- entered << [Thread.current, options]
317
+ entered << [Process.pid, options]
289
318
  release.pop
290
319
  { fixture: 'committed deletion' }
291
320
  end
@@ -294,21 +323,20 @@ describe 'session maintenance command workers' do
294
323
  console.instance_variable_get(:@events) << [:command, value.inspect]
295
324
  end
296
325
  Timeout.timeout(2) { console.submit('/sessions cleanup') }
297
- thread, options = Timeout.timeout(2) { entered.pop }
298
- expect(thread).not_to eq(owner)
326
+ pid, options = Timeout.timeout(2) { entered.pop }
327
+ expect(pid).not_to eq(owner)
299
328
  expect(options[:dry_run]).to be(false)
300
329
  console.handle("\u0014")
301
330
  console.handle(:page_up)
302
331
  console.submit('/sessions cleanup')
303
- expect(PWN::SessionCleanup).to have_received(:maintain).once
332
+ expect(entered).to be_empty
304
333
  console.handle("\u0003")
305
- expect(thread).to be_alive
306
- release << true
307
- expect(thread.join(2)).to eq(thread)
334
+ expect(console.instance_variable_get(:@worker)).not_to be_alive
308
335
  console.send(:drain)
309
336
  expect(console.send(:busy?)).to be_falsey
310
337
  text = console.instance_variable_get(:@timeline).map { |row| row[1] }.join
311
- expect(text).to include('committed deletion', 'not undone')
338
+ expect(text).to include('not undone')
339
+ expect(text).not_to include('committed deletion')
312
340
  expect(text).not_to include('Request cancelled')
313
341
  ensure
314
342
  release << true if release
@@ -317,7 +345,7 @@ describe 'session maintenance command workers' do
317
345
  console.instance_variable_get(:@tool_input)&.close
318
346
  end
319
347
 
320
- it 'keeps navigation and input alive through the real maintenance learning lock and queues errors' do
348
+ it 'defers a real maintenance learning lock without blocking navigation and queues errors' do
321
349
  require File.expand_path('../../../../../etc/default_cron/session_cleanup', __dir__)
322
350
  allow(PWN::SessionCleanup).to receive(:legacy_owners).and_return([])
323
351
  stub_const('PWN::AI::Agent::Learning::LEARNING_FILE', File.join(@tmp, '.pwn', 'learning.jsonl'))
@@ -325,21 +353,17 @@ describe 'session maintenance command workers' do
325
353
  reader, writer = IO.pipe
326
354
  console.instance_variable_set(:@input, reader)
327
355
  console.instance_variable_set(:@tool_input, writer)
328
- events = console.instance_variable_get(:@events)
329
- output = PWN::Plugins::REPL::AIConsole::EventIO.new(events)
330
- allow(PWN::Plugins::REPL).to receive(:pwn_ai_print_result).and_wrap_original do |method, **options|
331
- method.call(options.merge(output: output))
332
- end
333
356
  lock = PWN::AI::Agent::Learning.send(:learning_lock)
334
- entered = Queue.new
335
- allow(PWN::AI::Agent::Learning).to receive(:migrate_evidence).and_wrap_original do |method, **options|
357
+ entered = PWNTestPipeQueue.new
358
+ # Busy Learning now defers at maintenance admission, before migration.
359
+ allow(PWN::SessionCleanup).to receive(:maintenance_transaction).and_wrap_original do |method, **options|
336
360
  entered << true
337
- method.call(options)
361
+ method.call(**options)
338
362
  end
339
363
  Timeout.timeout(2) { console.submit('/sessions cleanup') }
340
364
  Timeout.timeout(2) { entered.pop }
341
365
  worker = console.instance_variable_get(:@worker)
342
- expect(worker.join(0.05)).to be_nil
366
+ expect(worker.join(5)).to eq(worker)
343
367
  console.submit('/input fixture')
344
368
  expect(reader.read_nonblock(8)).to eq("fixture\n")
345
369
  console.handle("\u0014")
@@ -350,6 +374,16 @@ describe 'session maintenance command workers' do
350
374
  lock = nil
351
375
  expect(worker.join(5)).to eq(worker)
352
376
  console.send(:drain)
377
+ # A busy writer defers before deletion admission without waiting for release.
378
+ # A fresh command must still delete, proving the stop state does not leak.
379
+ expect(File).to exist(File.join(PWN::Sessions.sessions_dir, "#{sid}.jsonl"))
380
+ ledgers = Dir.glob(File.join(@tmp, '.pwn', 'maintenance', 'runs', '*.json')).map { |path| JSON.parse(File.read(path)) }
381
+ expect(ledgers.last).to include('state' => 'blocked')
382
+ expect(ledgers.last.dig('result', 'evidence_migration', 'deferred')).to eq('learning_writer_active')
383
+ expect(console.send(:busy?)).to be_falsey
384
+ console.submit('/sessions cleanup')
385
+ expect(console.instance_variable_get(:@worker).join(5)).not_to be_nil
386
+ console.send(:drain)
353
387
  expect(File).not_to exist(File.join(PWN::Sessions.sessions_dir, "#{sid}.jsonl"))
354
388
  expect(console.instance_variable_get(:@timeline).select { |row| row[0] == :command }.map { |row| row[1] }.join).to include('deleted: 1')
355
389
  allow(PWN::SessionCleanup).to receive(:maintain).and_raise(IOError, 'fixture maintenance failed')
@@ -377,17 +411,18 @@ describe 'session maintenance command workers' do
377
411
  reader, writer = IO.pipe
378
412
  console.instance_variable_set(:@input, reader)
379
413
  console.instance_variable_set(:@tool_input, writer)
380
- entered = Queue.new
381
- release = Queue.new
382
- allow(PWN::SessionCleanup).to receive(:run).and_wrap_original do |method, **options|
414
+ entered = PWNTestPipeQueue.new
415
+ release = PWNTestPipeQueue.new
416
+ # Pause inside the admitted deletion transaction, not before run checks stop.
417
+ # Let the approved transaction complete; hard cancellation is tested above.
418
+ allow(PWN::Sessions).to receive(:delete).and_wrap_original do |method, **options|
383
419
  entered << true
384
420
  release.pop
385
- method.call(**options)
421
+ method.call(options)
386
422
  end
387
423
  Timeout.timeout(2) { console.handle("\r") }
388
424
  Timeout.timeout(2) { entered.pop }
389
425
  console.handle("\e")
390
- console.handle("\u0003")
391
426
  release << true
392
427
  expect(console.instance_variable_get(:@worker).join(5)).not_to be_nil
393
428
  console.send(:drain)
@@ -549,14 +584,14 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
549
584
  end
550
585
 
551
586
  it 'paints provisional and asynchronously updated summary titles in the actual SESSION pane' do
552
- reached = Queue.new
553
- release = Queue.new
554
- owner = Thread.current
587
+ reached = PWNTestPipeQueue.new
588
+ release = PWNTestPipeQueue.new
589
+ owner = Process.pid
555
590
  PWN::Env[:ai] = { active: :openai, openai: { model: 'active-title-model', key: 'fixture-key' } }
556
591
  allow(PWN::AI::Router).to receive(:summarize).and_call_original
557
592
  expect(PWN::AI::Ollama).not_to receive(:chat)
558
- expect(PWN::AI::OpenAI).to receive(:chat).with(hash_including(model: 'active-title-model', request: a_string_including('overall conversation', 'at most 7 words'))).once do
559
- expect(Thread.current).not_to eq(owner)
593
+ allow(PWN::AI::OpenAI).to receive(:chat).with(hash_including(model: 'active-title-model', request: a_string_including('overall conversation', 'at most 7 words'))) do
594
+ expect(Process.pid).not_to eq(owner)
560
595
  reached << true
561
596
  release.pop
562
597
  JSON.generate(title: 'Login and certificate repair plus automated rotation verification', summary: 'Investigated login and repaired certificates.')
@@ -578,13 +613,13 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
578
613
  @pry.config.pwn_ai_session_id = PWN::Sessions.create[:id]
579
614
  next "\r"
580
615
  end
581
- if !reached.empty? && !listed
616
+ # The provider barrier can precede the IPC reader's next rendered frame.
617
+ provisional_painted = @paint.join.include?('SESSION Investigate login and repair all expired certificates')
618
+ if !reached.empty? && !listed && provisional_painted
582
619
  listed = true
583
- keys.concat("/sessions list\n".chars)
584
- end
585
- if listed && @rendered.map(&:last).join.include?('active: true') && !unblocked
586
620
  expect(@paint.join).to include('SESSION Investigate login and repair all expired certificates')
587
- expect(@rendered.map(&:last).join).to include(@pry.config.pwn_ai_session_id, 'title: Investigate login and repair all expired certificates')
621
+ end
622
+ if listed && !unblocked
588
623
  expect(PWN::Sessions.title_status(session_id: @pry.config.pwn_ai_session_id)[:is_summarized]).to be(false)
589
624
  unblocked = true
590
625
  release << true
@@ -607,7 +642,7 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
607
642
  requested = false
608
643
  selected = false
609
644
  keys = "/sessions list\n".chars
610
- expect(PWN::AI::Agent::Loop).to receive(:run).with(hash_including(request: 'Continue fixture', session_id: sid)) do |opts|
645
+ allow(PWN::AI::Agent::Loop).to receive(:run).with(hash_including(request: 'Continue fixture', session_id: sid)) do |opts|
611
646
  expect(PWN::Sessions.to_llm_messages(session_id: opts[:session_id]).map { |row| row[:content] }).to eq(['Prior fixture request', 'Prior fixture answer'])
612
647
  opts[:on_tool].call('fixture callback', {}, 'fixture result')
613
648
  'Resumed fixture complete'
@@ -836,7 +871,7 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
836
871
  allow(PWN::AI::Agent::PromptBuilder).to receive(:build).and_wrap_original do |method, opts|
837
872
  method.call(opts.merge(thin: true))
838
873
  end
839
- expect(PWN::AI::OpenAI).to receive(:open_ai_rest_call) do |opts|
874
+ allow(PWN::AI::OpenAI).to receive(:open_ai_rest_call) do |opts|
840
875
  expect(opts[:http_body][:messages].find { |message| message[:role] == 'system' }[:content]).to start_with("Saved role\nsecond line")
841
876
  { choices: [{ message: { role: 'assistant', content: 'Role forwarded.', tool_calls: [] } }] }.to_json
842
877
  end
@@ -910,13 +945,15 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
910
945
 
911
946
  it 'runs the real Loop from the public launch, steering a blocked model without a second input reader' do
912
947
  loop_module = PWN::AI::Agent::Loop
913
- entered = Queue.new
948
+ entered = PWNTestPipeQueue.new
949
+ recorded = PWNTestPipeQueue.new
914
950
  calls = []
915
951
  Thread.current[:pwn_console_probe] = :copied
916
952
  allow(loop_module).to receive(:call_engine) do |opts|
917
953
  expect(Thread.current[:pwn_console_probe]).to eq(:copied)
918
954
  expect(Thread.current[:pwn_steering_input].reader).to be_nil
919
955
  calls << opts[:messages].map(&:dup)
956
+ recorded << calls.last
920
957
  if calls.length == 1
921
958
  entered << true
922
959
  Queue.new.pop
@@ -937,8 +974,9 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
937
974
  nil
938
975
  end
939
976
  end
977
+ calls << recorded.pop until recorded.empty?
940
978
  expect(calls.length).to eq(2)
941
- expect(calls.last.select { |row| row[:role] == 'user' }.map { |row| row[:content] }).to include('what color is a lemon?')
979
+ expect(calls.last.select { |row| row[:role].to_s == 'user' }.map { |row| row[:content] }).to include('what color is a lemon?')
942
980
  expect(@paint.join).to include('pwn-ai', 'ASSISTANT', 'MISSION', 'steering')
943
981
  ensure
944
982
  Thread.current[:pwn_console_probe] = nil
@@ -947,7 +985,7 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
947
985
  it 'opens a draft-preserving swarm workspace and sends only after explicit confirmation' do
948
986
  allow(PWN::AI::Agent::Swarm).to receive(:personas).and_return(scout: { role: 'Inspect evidence', engine: 'ollama', model: 'fixture' })
949
987
  allow(PWN::AI::Agent::Swarm).to receive(:create).and_return(swarm_id: 'fixture')
950
- allow(PWN::AI::Agent::Swarm).to receive(:ask).and_return(reply: 'Workspace result')
988
+ allow(PWN::AI::Agent::Swarm).to receive(:ask).with(include(name: 'scout', request: 'mission draft')).and_return(reply: 'Workspace result')
951
989
  keys = "mission draft\u0007a".chars
952
990
  stage = 0
953
991
  launch(keys) do
@@ -967,11 +1005,11 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
967
1005
  "\u0004"
968
1006
  end
969
1007
  end
970
- expect(PWN::AI::Agent::Swarm).to have_received(:ask).with(include(name: 'scout', request: 'mission draft')).once
1008
+ expect(@paint.join).to include('Workspace result')
971
1009
  end
972
1010
 
973
1011
  it 'cancels a blocked model on Ctrl+C, returns idle, and does not kill another worker' do
974
- entered = Queue.new
1012
+ entered = PWNTestPipeQueue.new
975
1013
  foreign = Thread.new { sleep }
976
1014
  allow(PWN::AI::Agent::Loop).to receive(:call_engine) {
977
1015
  entered << true
@@ -983,7 +1021,7 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
983
1021
  if !cancelled && !entered.empty?
984
1022
  cancelled = true
985
1023
  "\u0003"
986
- elsif @paint.any? { |row| row.include?('Request cancelled at a safe boundary') }
1024
+ elsif @paint.any? { |row| row.include?('Mission cancelled; all owned processes reaped') }
987
1025
  "\u0004"
988
1026
  else
989
1027
  Thread.pass
@@ -998,13 +1036,13 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
998
1036
 
999
1037
  [0, 3.1].each do |startup_delay|
1000
1038
  it "leaves a provider blocked in native IO without waiting for that read to return (startup #{startup_delay}s)" do
1001
- entered = Queue.new
1002
- returned = Queue.new
1039
+ entered = PWNTestPipeQueue.new
1040
+ returned = PWNTestPipeQueue.new
1003
1041
  provider = nil
1004
1042
  started = nil
1005
1043
  allow(PWN::AI::Agent::Loop).to receive(:call_engine) do
1006
1044
  Thread.handle_interrupt(Exception => :never) do
1007
- entered << Thread.current
1045
+ entered << File.read('/proc/thread-self/stat').split.first
1008
1046
  sleep 8
1009
1047
  returned << true
1010
1048
  end
@@ -1013,12 +1051,12 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
1013
1051
  launched = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1014
1052
  launch(keys, startup_delay: startup_delay, render_delay: startup_delay.positive? ? 0.02 : 0) do
1015
1053
  provider ||= entered.pop unless entered.empty?
1016
- if !started && provider&.status == 'sleep'
1054
+ if !started && provider && File.read("/proc/#{provider}/status").match?(/^State:\s+S/)
1017
1055
  # Elapsed is painted before call_engine: it is not a provider barrier.
1018
1056
  # Time from delivered Ctrl+C, excluding startup and per-key rendering.
1019
1057
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1020
1058
  "\u0003"
1021
- elsif @paint.any? { |row| row.include?('Request cancelled at a safe boundary') }
1059
+ elsif @paint.any? { |row| row.include?('Mission cancelled; all owned processes reaped') }
1022
1060
  "\u0004"
1023
1061
  else
1024
1062
  Thread.pass
@@ -1029,24 +1067,17 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
1029
1067
  expect(started - launched).to be >= startup_delay
1030
1068
  expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).to be < 3
1031
1069
  expect(returned).to be_empty
1070
+ expect(File.exist?("/proc/#{provider}")).to be(false)
1032
1071
  ensure
1033
- # Cancellation deliberately abandons masked IO. Release this fixture only
1034
- # after measuring return, then reap it before RSpec removes its mocks.
1035
- provider ||= entered.pop unless entered.empty?
1036
- begin
1037
- provider&.wakeup if provider&.alive?
1038
- rescue ThreadError
1039
- nil # The provider exited between alive? and wakeup.
1040
- end
1041
- provider&.join(2)
1042
- expect(provider).not_to be_alive if provider
1072
+ entered&.close
1073
+ returned&.close
1043
1074
  end
1044
1075
  end
1045
1076
 
1046
1077
  it 'forwards tool input, preserves completed evidence before steering, and locks settings while running' do
1047
- entered = Queue.new
1078
+ entered = PWNTestPipeQueue.new
1048
1079
  calls = 0
1049
- observed = nil
1080
+ observed = PWNTestPipeQueue.new
1050
1081
  allow(PWN::AI::Agent::Loop).to receive(:call_engine) do
1051
1082
  calls += 1
1052
1083
  if calls == 1
@@ -1055,10 +1086,11 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
1055
1086
  { role: 'assistant', content: 'Yellow.', tool_calls: [] }
1056
1087
  end
1057
1088
  end
1058
- expect(PWN::AI::Agent::Dispatch).to receive(:call).once do
1089
+ allow(PWN::AI::Agent::Dispatch).to receive(:call) do
1059
1090
  entered << true
1060
- observed = $stdin.gets
1061
- JSON.generate(success: true, result: { stdout: observed, exitstatus: 0 })
1091
+ answer = $stdin.gets
1092
+ observed << answer
1093
+ JSON.generate(success: true, result: { stdout: answer, exitstatus: 0 })
1062
1094
  end
1063
1095
  expect(PWN::Plugins::REPL).not_to receive(:pwn_ai_run_model)
1064
1096
  keys = "what color is a passion fruit?\n".chars
@@ -1075,14 +1107,15 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
1075
1107
  nil
1076
1108
  end
1077
1109
  end
1078
- expect(observed).to eq("tool answer\n")
1110
+ expect(observed.pop).to eq("tool answer\n")
1111
+ expect(observed).to be_empty
1079
1112
  expect(@paint.join).to include('TOOL', 'RESULT', 'tool answer', 'Settings and new requests wait')
1080
1113
  expect(@paint.join).to include('tools 1')
1081
1114
  end
1082
1115
 
1083
1116
  it 'closes the tool prompt pipe on busy exit and joins rather than orphaning the active request' do
1084
- entered = Queue.new
1085
- finished = Queue.new
1117
+ entered = PWNTestPipeQueue.new
1118
+ finished = PWNTestPipeQueue.new
1086
1119
  allow(PWN::AI::Agent::Loop).to receive(:call_engine).and_return(
1087
1120
  { role: 'assistant', tool_calls: [{ id: 'prompt', type: 'function', function: { name: 'shell', arguments: '{}' } }] }
1088
1121
  )
@@ -1094,7 +1127,7 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
1094
1127
  end
1095
1128
  keys = "what color is a lemon?\n".chars
1096
1129
  launch(keys) { entered.empty? ? nil : "\u0004" }
1097
- expect(finished.pop).to be_nil
1130
+ expect(finished).to be_empty # Hard cancellation does not resume the tool to finish its writes.
1098
1131
  end
1099
1132
 
1100
1133
  it 'wraps menu selection without changing the draft or persistent history, then recalls after Escape' do
@@ -2500,14 +2533,14 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
2500
2533
 
2501
2534
  it 'repaints settings and theme changed by a command worker without worker drawing or a wakeup key' do
2502
2535
  PWN::Env[:ai] = { active: :ollama, ollama: { model: 'settings-fixture' } }
2503
- owner = Thread.current
2536
+ owner = Process.pid
2504
2537
  painted = []
2505
2538
  allow_any_instance_of(PWN::Plugins::REPL::AIConsole::Console).to receive(:put).and_wrap_original do |method, *args|
2506
2539
  painted << args
2507
2540
  method.call(*args)
2508
2541
  end
2509
2542
  allow(PWN::Cron).to receive(:run) do
2510
- expect(Thread.current).not_to eq(owner)
2543
+ expect(Process.pid).not_to eq(owner)
2511
2544
  slot = PWN::Env[:ai][PWN::Env[:ai][:active].to_sym]
2512
2545
  slot[:system_role_content] = 'worker settings role'
2513
2546
  slot[:temp] = 0.42
@@ -2559,6 +2592,10 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
2559
2592
  allow(PWN::Banner).to receive(:mini_names).and_return([:falling_blocks])
2560
2593
  allow(PWN::Plugins::REPL).to receive(:persist_ai_selection).and_return(false)
2561
2594
  expect(PWN::Plugins::REPL).not_to receive(:pwn_ai_apply_model)
2595
+ allow(Time).to receive(:now).and_return(Time.now)
2596
+ # Compare model/settings/artwork, not the command's live status/timer.
2597
+ # Mission readiness and cancellation have separate regressions.
2598
+ allow_any_instance_of(PWN::Plugins::REPL::AIConsole::Console).to receive_messages(elapsed: '0.0s', state: 'fixture status')
2562
2599
  original = Marshal.dump(PWN::Env[:ai])
2563
2600
  keys = "/model grok grok-4.6\n".chars
2564
2601
  cancelled = false
@@ -2572,7 +2609,8 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
2572
2609
  cancel_key
2573
2610
  elsif cancelled
2574
2611
  expect(Marshal.dump(PWN::Env[:ai])).to eq(original)
2575
- expect(rendered_header).to eq(baseline)
2612
+ first_diff = baseline.chars.zip(rendered_header.chars).index { |left, right| left != right }
2613
+ expect(rendered_header).to eq(baseline), "header delta at #{first_diff}: #{baseline[first_diff.to_i, 180].inspect} -> #{rendered_header[first_diff.to_i, 180].inspect}"
2576
2614
  "\u0004"
2577
2615
  else
2578
2616
  Thread.pass
@@ -2606,7 +2644,7 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
2606
2644
  PWN::Env[:ai][:openai] = { model: 'old', reasoning_effort: 'medium' }
2607
2645
  allow(PWN::Plugins::REPL).to receive(:persist_ai_selection).and_return(false)
2608
2646
  allow(PWN::AI::OpenAI).to receive(:get_models).and_return(data: [{ id: 'gpt-6-astra', supported_reasoning_levels: [{ effort: 'low' }, { effort: 'medium' }, { effort: 'high' }] }])
2609
- expect(PWN::AI::OpenAI).to receive(:chat_with_tools).with(hash_including(reasoning_effort: 'high')) do
2647
+ allow(PWN::AI::OpenAI).to receive(:chat_with_tools).with(hash_including(reasoning_effort: 'high')) do
2610
2648
  { choices: [{ message: { role: 'assistant', content: 'Effort accepted.', tool_calls: [] } }] }
2611
2649
  end
2612
2650
  keys = "/model openai gpt-6-astra\n".chars
@@ -2631,13 +2669,22 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
2631
2669
  PWN::Sessions.append(session_id: sid, role: 'user', content: 'Previous fixture question')
2632
2670
  PWN::Sessions.append(session_id: sid, role: 'assistant', content: 'Previous fixture answer')
2633
2671
  allow(PWN::Plugins::REPL).to receive(:persist_ai_selection).and_return(false)
2634
- expect(PWN::AI::OpenAI).to receive(:chat_with_tools) do |opts|
2672
+ allow(PWN::AI::OpenAI).to receive(:chat_with_tools) do |opts|
2635
2673
  expect(PWN::Env.dig(:ai, :openai, :model)).to eq('console-fixture')
2636
2674
  expect(opts[:messages]).to include(hash_including(role: 'user', content: 'Previous fixture question'), hash_including(role: 'assistant', content: 'Previous fixture answer'))
2637
2675
  { choices: [{ message: { role: 'assistant', content: 'Selected route.', tool_calls: [] } }] }
2638
2676
  end
2639
- keys = "/model openai console-fixture\n/sessions resume resumed console\nwhat color is a lemon?\n".chars
2640
- launch(keys) { @paint.any? { |row| row.include?('Selected route.') } ? "\u0004" : nil }
2677
+ keys = "/model openai console-fixture\n/sessions resume resumed console\n".chars
2678
+ submitted = false
2679
+ launch(keys) do
2680
+ if !submitted && @pry.config.pwn_ai_session_id == sid
2681
+ submitted = true
2682
+ keys.concat("what color is a lemon?\n".chars)
2683
+ nil
2684
+ elsif @paint.any? { |row| row.include?('Selected route.') }
2685
+ "\u0004"
2686
+ end
2687
+ end
2641
2688
  expect(@pry.config.pwn_ai_session_id).to eq(sid)
2642
2689
  expect(PWN::Sessions.load(session_id: sid).map { |row| row[:content] }.join).to include('Selected route.')
2643
2690
  end
@@ -2701,9 +2748,9 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
2701
2748
  end
2702
2749
 
2703
2750
  it 'keeps network slash commands off the event thread so tool prompts can receive explicit input' do
2704
- entered = Queue.new
2751
+ entered = PWNTestPipeQueue.new
2705
2752
  expect(PWN::AI::Agent::Loop).not_to receive(:run)
2706
- expect(PWN::Plugins::REPL).to receive(:pwn_ai_dispatch_slash!).with(request: '/mcp call fixture', pry: anything, raise_errors: true) do
2753
+ allow(PWN::Plugins::REPL).to receive(:pwn_ai_dispatch_slash!).with(request: '/mcp call fixture', pry: anything, raise_errors: true) do
2707
2754
  entered << true
2708
2755
  puts "local command received: #{$stdin.gets}"
2709
2756
  true