pwn 0.5.784 → 0.5.787
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.ruby-version +1 -1
- data/Gemfile +6 -6
- data/README.md +37 -0
- data/bin/pwn +2 -1
- data/bin/pwn_web_cache_deception +35 -7
- data/build_gem.sh +1 -1
- data/documentation/AI-Integration.md +7 -5
- data/documentation/Agent-Tool-Registry.md +2 -2
- data/documentation/Backup-Retention.md +69 -15
- data/documentation/Configuration.md +10 -6
- data/documentation/Contributing.md +1 -1
- data/documentation/Cron.md +2 -2
- data/documentation/Diagrams.md +2 -2
- data/documentation/General-PWN-Usage.md +4 -3
- data/documentation/Harness-Evaluation.md +1 -1
- data/documentation/Home.md +6 -5
- data/documentation/How-PWN-Works.md +6 -5
- data/documentation/Installation.md +12 -9
- data/documentation/Mission-Cancellation.md +134 -0
- data/documentation/Operability.md +18 -0
- data/documentation/Persistence.md +7 -5
- data/documentation/Plugin-Degradation.md +14 -0
- data/documentation/Plugins.md +3 -2
- data/documentation/Policy-Benchmark.md +32 -33
- data/documentation/Runtime-Source-Protection.md +162 -0
- data/documentation/Transparent-Browser.md +11 -0
- data/documentation/Troubleshooting.md +3 -2
- data/documentation/What-is-PWN.md +2 -2
- data/documentation/YubiKey-Vaults.md +185 -0
- data/documentation/YubiKey.md +218 -0
- data/documentation/diagrams/cron-scheduling.svg +130 -130
- data/documentation/diagrams/dot/cron-scheduling.dot +3 -3
- data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
- data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
- data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +1 -1
- data/documentation/diagrams/dot/reinforcement-learning.dot +2 -2
- data/documentation/diagrams/persistence-filesystem.svg +172 -142
- data/documentation/diagrams/plugin-ecosystem.svg +64 -64
- data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +1 -1
- data/documentation/diagrams/reinforcement-learning.svg +89 -89
- data/documentation/pwn-ai-Agent.md +10 -4
- data/etc/default_cron/session_cleanup.rb +43 -27
- data/etc/default_skills/pwn/ai/agent/mission_namespace/SKILL.md +21 -0
- data/etc/default_skills/pwn/ai/agent/tools/browser_act/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +27 -26
- data/etc/default_skills/pwn/ai/agent/tools/recon_run/SKILL.md +25 -15
- data/etc/default_skills/pwn/ai/agent/tools/workspace_tool/SKILL.md +24 -0
- data/etc/default_skills/pwn/ai/server_tools/SKILL.md +25 -0
- data/etc/default_skills/pwn/backups/SKILL.md +54 -0
- data/etc/default_skills/pwn/plugins/recon/SKILL.md +12 -1
- data/etc/default_skills/pwn/plugins/yubikey/SKILL.md +157 -0
- data/etc/default_skills/pwn/plugins/yubikey/fido/SKILL.md +189 -0
- data/etc/default_skills/pwn/plugins/yubikey/hsmauth/SKILL.md +83 -0
- data/etc/default_skills/pwn/plugins/yubikey/management/SKILL.md +80 -0
- data/etc/default_skills/pwn/plugins/yubikey/oath/SKILL.md +115 -0
- data/etc/default_skills/pwn/plugins/yubikey/openpgp/SKILL.md +144 -0
- data/etc/default_skills/pwn/plugins/yubikey/otp/SKILL.md +97 -0
- data/etc/default_skills/pwn/plugins/yubikey/piv/SKILL.md +110 -0
- data/etc/default_skills/pwn/plugins/yubikey/protocol/SKILL.md +21 -0
- data/etc/default_skills/pwn/plugins/yubikey/security_domain/SKILL.md +75 -0
- data/etc/default_skills/pwn/plugins/yubikey/security_domain/scp/SKILL.md +46 -0
- data/etc/default_skills/pwn/plugins/yubikey/transport/SKILL.md +98 -0
- data/etc/default_skills/pwn/redaction/SKILL.md +13 -1
- data/lib/pwn/ai/agent/dispatch.rb +26 -15
- data/lib/pwn/ai/agent/learning.rb +14 -18
- data/lib/pwn/ai/agent/loop.rb +17 -1
- data/lib/pwn/ai/agent/mission_namespace.rb +331 -0
- data/lib/pwn/ai/agent/policy_evaluation.rb +334 -36
- data/lib/pwn/ai/agent/prompt_builder.rb +13 -2
- data/lib/pwn/ai/agent/request_runtime.rb +122 -18
- data/lib/pwn/ai/agent/reward.rb +5 -3
- data/lib/pwn/ai/agent/skill_consolidation.rb +3 -2
- data/lib/pwn/ai/agent/skill_review.rb +9 -5
- data/lib/pwn/ai/agent/swarm.rb +4 -0
- data/lib/pwn/ai/agent/tools/browser_act.rb +32 -0
- data/lib/pwn/ai/agent/tools/loot_query.rb +11 -1
- data/lib/pwn/ai/agent/tools/recon_run.rb +11 -1
- data/lib/pwn/ai/agent/tools/workspace_tool.rb +29 -0
- data/lib/pwn/ai/agent/turn_finalizer.rb +6 -2
- data/lib/pwn/ai/agent.rb +1 -0
- data/lib/pwn/ai/anthropic.rb +8 -33
- data/lib/pwn/ai/cli.rb +34 -5
- data/lib/pwn/ai/gemini.rb +1 -0
- data/lib/pwn/ai/grok.rb +39 -28
- data/lib/pwn/ai/ollama.rb +4 -2
- data/lib/pwn/ai/open_ai.rb +11 -32
- data/lib/pwn/ai/open_web_ui.rb +62 -4
- data/lib/pwn/ai/router.rb +8 -1
- data/lib/pwn/ai/server_tools.rb +231 -0
- data/lib/pwn/ai.rb +1 -0
- data/lib/pwn/backups.rb +349 -0
- data/lib/pwn/config.rb +70 -39
- data/lib/pwn/migrate.rb +85 -280
- data/lib/pwn/module_skills.rb +7 -0
- data/lib/pwn/plugins/ai_sandbox.rb +538 -1
- data/lib/pwn/plugins/authentication_helper.rb +74 -4
- data/lib/pwn/plugins/basic_auth.rb +2 -1
- data/lib/pwn/plugins/google_workspace.rb +4 -21
- data/lib/pwn/plugins/jobs.rb +1 -1
- data/lib/pwn/plugins/log.rb +3 -1
- data/lib/pwn/plugins/preflight_checker.rb +34 -9
- data/lib/pwn/plugins/recon.rb +4 -1
- data/lib/pwn/plugins/repl/ai.rb +293 -26
- data/lib/pwn/plugins/repl/ai_console.rb +268 -88
- data/lib/pwn/plugins/repl/ai_swarm.rb +177 -58
- data/lib/pwn/plugins/repl/mesh.rb +14 -19
- data/lib/pwn/plugins/repl/vault.rb +4 -15
- data/lib/pwn/plugins/repl.rb +9 -216
- data/lib/pwn/plugins/sandbox/driver.rb +4 -0
- data/lib/pwn/plugins/transparent_browser.rb +215 -0
- data/lib/pwn/plugins/vault.rb +412 -135
- data/lib/pwn/plugins/yubikey/fido.rb +1288 -0
- data/lib/pwn/plugins/yubikey/hsmauth.rb +439 -0
- data/lib/pwn/plugins/yubikey/management.rb +686 -0
- data/lib/pwn/plugins/yubikey/oath.rb +766 -0
- data/lib/pwn/plugins/yubikey/openpgp.rb +1063 -0
- data/lib/pwn/plugins/yubikey/otp.rb +897 -0
- data/lib/pwn/plugins/yubikey/piv.rb +1315 -0
- data/lib/pwn/plugins/yubikey/protocol.rb +341 -0
- data/lib/pwn/plugins/yubikey/security_domain/scp.rb +482 -0
- data/lib/pwn/plugins/yubikey/security_domain.rb +464 -0
- data/lib/pwn/plugins/yubikey/transport.rb +1017 -0
- data/lib/pwn/plugins/yubikey.rb +844 -0
- data/lib/pwn/plugins.rb +1 -0
- data/lib/pwn/redaction.rb +69 -12
- data/lib/pwn/session_trace.rb +4 -0
- data/lib/pwn/setup.rb +114 -24
- data/lib/pwn/version.rb +1 -1
- data/lib/pwn/www/app_cobalt_io.rb +16 -5
- data/lib/pwn/www/bug_crowd.rb +16 -5
- data/lib/pwn/www/coinbase_pro.rb +16 -5
- data/lib/pwn/www/github.rb +30 -14
- data/lib/pwn/www/paypal.rb +15 -4
- data/lib/pwn/www/synack.rb +16 -5
- data/lib/pwn/www/twitter.rb +17 -6
- data/lib/pwn.rb +1 -0
- data/spec/documentation/installation_md_spec.rb +1 -1
- data/spec/fixtures/yubikey/apdu_frames.json +162 -0
- data/spec/integration/authentication_helper_oath_spec.rb +117 -0
- data/spec/integration/controller_spill_spec.rb +48 -0
- data/spec/integration/harness_process_spec.rb +97 -0
- data/spec/integration/mission_cancel_spec.rb +79 -0
- data/spec/integration/mission_curses_pty_spec.rb +44 -0
- data/spec/integration/readme_security_spec.rb +47 -0
- data/spec/integration/web_cache_deception_yubikey_spec.rb +159 -0
- data/spec/lib/pwn/ai/agent/dispatch_spec.rb +6 -3
- data/spec/lib/pwn/ai/agent/dispatch_vault_safety_spec.rb +86 -0
- data/spec/lib/pwn/ai/agent/injection_guard_spec.rb +2 -0
- data/spec/lib/pwn/ai/agent/learning_spec.rb +4 -4
- data/spec/lib/pwn/ai/agent/loop_artifacts_spec.rb +5 -0
- data/spec/lib/pwn/ai/agent/loop_spec.rb +104 -29
- data/spec/lib/pwn/ai/agent/mission_namespace_spec.rb +138 -0
- data/spec/lib/pwn/ai/agent/policy_evaluation_spec.rb +10 -23
- data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +48 -0
- data/spec/lib/pwn/ai/agent/shell_search_effect_spec.rb +44 -0
- data/spec/lib/pwn/ai/agent/solve_spec.rb +6 -4
- data/spec/lib/pwn/ai/agent/tools/browser_act_spec.rb +44 -0
- data/spec/lib/pwn/ai/agent/tools/loot_query_yubikey_spec.rb +55 -0
- data/spec/lib/pwn/ai/agent/tools/recon_run_yubikey_spec.rb +43 -0
- data/spec/lib/pwn/ai/agent/tools/workspace_tool_spec.rb +20 -0
- data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +7 -0
- data/spec/lib/pwn/ai/anthropic_vault_enrollment_spec.rb +37 -0
- data/spec/lib/pwn/ai/cli_spec.rb +8 -16
- data/spec/lib/pwn/ai/oauth_vault_persistence_spec.rb +195 -0
- data/spec/lib/pwn/ai/open_ai_spec.rb +2 -2
- data/spec/lib/pwn/ai/open_web_ui_workspace_spec.rb +36 -0
- data/spec/lib/pwn/ai/router_spec.rb +1 -1
- data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
- data/spec/lib/pwn/backups_spec.rb +129 -0
- data/spec/lib/pwn/config_redaction_yubikey_spec.rb +28 -0
- data/spec/lib/pwn/config_yubikey_spec.rb +92 -0
- data/spec/lib/pwn/migrate_spec.rb +29 -81
- data/spec/lib/pwn/migrate_yubikey_spec.rb +128 -0
- data/spec/lib/pwn/module_skills_spec.rb +8 -0
- data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +230 -0
- data/spec/lib/pwn/plugins/authentication_helper_native_yubikey_spec.rb +40 -0
- data/spec/lib/pwn/plugins/authentication_helper_yubikey_spec.rb +90 -0
- data/spec/lib/pwn/plugins/capability_broker/daemon_spec.rb +1 -1
- data/spec/lib/pwn/plugins/google_workspace_spec.rb +17 -35
- data/spec/lib/pwn/plugins/log_yubikey_spec.rb +47 -0
- data/spec/lib/pwn/plugins/preflight_checker_native_yubikey_spec.rb +19 -0
- data/spec/lib/pwn/plugins/preflight_checker_yubikey_spec.rb +55 -0
- data/spec/lib/pwn/plugins/recon_yubikey_spec.rb +43 -0
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +110 -75
- data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
- data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +19 -16
- data/spec/lib/pwn/plugins/repl/ai_vault_edit_yubikey_spec.rb +103 -0
- data/spec/lib/pwn/plugins/repl/ai_yubikey_spec.rb +48 -0
- data/spec/lib/pwn/plugins/repl/mesh_vault_persistence_spec.rb +149 -0
- data/spec/lib/pwn/plugins/repl/vault_yaml_serialization_spec.rb +11 -0
- data/spec/lib/pwn/plugins/repl/vault_yubikey_spec.rb +39 -0
- data/spec/lib/pwn/plugins/repl_mesh_layout_spec.rb +2 -0
- data/spec/lib/pwn/plugins/repl_spec.rb +56 -5
- data/spec/lib/pwn/plugins/transparent_browser_jev_spec.rb +46 -0
- data/spec/lib/pwn/plugins/vault_native_yubikey_spec.rb +29 -0
- data/spec/lib/pwn/plugins/vault_publication_safety_spec.rb +258 -0
- data/spec/lib/pwn/plugins/vault_store_safety_spec.rb +241 -0
- data/spec/lib/pwn/plugins/vault_yubikey_safety_spec.rb +168 -0
- data/spec/lib/pwn/plugins/vault_yubikey_spec.rb +128 -0
- data/spec/lib/pwn/plugins/vault_yubikey_vector_spec.rb +45 -0
- data/spec/lib/pwn/plugins/yubikey/fido_spec.rb +854 -0
- data/spec/lib/pwn/plugins/yubikey/hsmauth_spec.rb +235 -0
- data/spec/lib/pwn/plugins/yubikey/management_spec.rb +277 -0
- data/spec/lib/pwn/plugins/yubikey/oath_spec.rb +349 -0
- data/spec/lib/pwn/plugins/yubikey/openpgp_spec.rb +810 -0
- data/spec/lib/pwn/plugins/yubikey/otp_spec.rb +337 -0
- data/spec/lib/pwn/plugins/yubikey/piv_spec.rb +646 -0
- data/spec/lib/pwn/plugins/yubikey/protocol_secure_spec.rb +28 -0
- data/spec/lib/pwn/plugins/yubikey/protocol_spec.rb +149 -0
- data/spec/lib/pwn/plugins/yubikey/protocol_vectors_spec.rb +18 -0
- data/spec/lib/pwn/plugins/yubikey/security_domain/scp_spec.rb +256 -0
- data/spec/lib/pwn/plugins/yubikey/security_domain/support.rb +153 -0
- data/spec/lib/pwn/plugins/yubikey/security_domain_spec.rb +296 -0
- data/spec/lib/pwn/plugins/yubikey/transport_spec.rb +760 -0
- data/spec/lib/pwn/plugins/yubikey_security_spec.rb +260 -0
- data/spec/lib/pwn/plugins/yubikey_spec.rb +209 -0
- data/spec/lib/pwn/plugins/yubikey_terminal_spec.rb +147 -0
- data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
- data/spec/lib/pwn/session_cleanup_default_spec.rb +7 -5
- data/spec/lib/pwn/session_cleanup_payload_spec.rb +11 -11
- data/spec/lib/pwn/session_cleanup_recovery_spec.rb +34 -20
- data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
- data/spec/lib/pwn/setup_vault_yubikey_spec.rb +61 -0
- data/spec/lib/pwn/setup_yubikey_spec.rb +40 -0
- data/spec/lib/pwn/www/login_yubikey_spec.rb +273 -0
- data/spec/spec_helper.rb +15 -0
- data/spec/support/harness/cli_faults.rb +13 -9
- data/spec/support/harness/controller_spill.rb +26 -0
- data/spec/support/harness/controller_state.rb +54 -0
- data/spec/support/harness/evaluate.rb +12 -1
- data/spec/support/harness/experiments.rb +1 -1
- data/spec/support/harness/fake_provider.rb +22 -6
- data/spec/support/harness/fault_preload.rb +3 -1
- data/spec/support/harness/mission_console.rb +54 -0
- data/spec/support/harness/mission_curses_pty.rb +41 -0
- data/spec/support/harness/mission_dispatch.rb +30 -0
- data/spec/support/harness/mission_fixture_preload.rb +24 -0
- data/spec/support/harness/mission_job_lifetime.rb +25 -0
- data/spec/support/harness/mission_legacy.rb +25 -0
- data/spec/support/harness/mission_normal_exit.rb +34 -0
- data/spec/support/harness/mission_swarm.rb +23 -0
- data/spec/support/harness/mission_unsupported.rb +29 -0
- data/spec/support/harness/package_acceptance.rb +33 -5
- data/spec/support/harness/session_acceptance.rb +4 -4
- data/spec/support/harness/swarm_acceptance.rb +1 -1
- data/spec/support/harness/swarm_control.rb +44 -0
- data/spec/support/mission_namespace.rb +14 -0
- data/spec/support/mission_pipe_queue.rb +33 -0
- data/third_party/pwn_rdoc.jsonl +605 -19
- metadata +129 -16
- data/scripts/benchmark_policy.rb +0 -376
- data/scripts/build_gate.rb +0 -58
- data/spec/integration/build_gate_spec.rb +0 -73
|
@@ -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
|
|
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 =
|
|
282
|
-
release =
|
|
283
|
-
owner =
|
|
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 << [
|
|
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
|
-
|
|
298
|
-
expect(
|
|
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(
|
|
332
|
+
expect(entered).to be_empty
|
|
304
333
|
console.handle("\u0003")
|
|
305
|
-
expect(
|
|
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('
|
|
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
|
|
@@ -325,16 +353,12 @@ 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 =
|
|
335
|
-
|
|
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 }
|
|
@@ -387,10 +411,10 @@ describe 'session maintenance command workers' do
|
|
|
387
411
|
reader, writer = IO.pipe
|
|
388
412
|
console.instance_variable_set(:@input, reader)
|
|
389
413
|
console.instance_variable_set(:@tool_input, writer)
|
|
390
|
-
entered =
|
|
391
|
-
release =
|
|
414
|
+
entered = PWNTestPipeQueue.new
|
|
415
|
+
release = PWNTestPipeQueue.new
|
|
392
416
|
# Pause inside the admitted deletion transaction, not before run checks stop.
|
|
393
|
-
#
|
|
417
|
+
# Let the approved transaction complete; hard cancellation is tested above.
|
|
394
418
|
allow(PWN::Sessions).to receive(:delete).and_wrap_original do |method, **options|
|
|
395
419
|
entered << true
|
|
396
420
|
release.pop
|
|
@@ -399,7 +423,6 @@ describe 'session maintenance command workers' do
|
|
|
399
423
|
Timeout.timeout(2) { console.handle("\r") }
|
|
400
424
|
Timeout.timeout(2) { entered.pop }
|
|
401
425
|
console.handle("\e")
|
|
402
|
-
console.handle("\u0003")
|
|
403
426
|
release << true
|
|
404
427
|
expect(console.instance_variable_get(:@worker).join(5)).not_to be_nil
|
|
405
428
|
console.send(:drain)
|
|
@@ -561,14 +584,14 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
|
|
|
561
584
|
end
|
|
562
585
|
|
|
563
586
|
it 'paints provisional and asynchronously updated summary titles in the actual SESSION pane' do
|
|
564
|
-
reached =
|
|
565
|
-
release =
|
|
566
|
-
owner =
|
|
587
|
+
reached = PWNTestPipeQueue.new
|
|
588
|
+
release = PWNTestPipeQueue.new
|
|
589
|
+
owner = Process.pid
|
|
567
590
|
PWN::Env[:ai] = { active: :openai, openai: { model: 'active-title-model', key: 'fixture-key' } }
|
|
568
591
|
allow(PWN::AI::Router).to receive(:summarize).and_call_original
|
|
569
592
|
expect(PWN::AI::Ollama).not_to receive(:chat)
|
|
570
|
-
|
|
571
|
-
expect(
|
|
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)
|
|
572
595
|
reached << true
|
|
573
596
|
release.pop
|
|
574
597
|
JSON.generate(title: 'Login and certificate repair plus automated rotation verification', summary: 'Investigated login and repaired certificates.')
|
|
@@ -590,13 +613,13 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
|
|
|
590
613
|
@pry.config.pwn_ai_session_id = PWN::Sessions.create[:id]
|
|
591
614
|
next "\r"
|
|
592
615
|
end
|
|
593
|
-
|
|
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
|
|
594
619
|
listed = true
|
|
595
|
-
keys.concat("/sessions list\n".chars)
|
|
596
|
-
end
|
|
597
|
-
if listed && @rendered.map(&:last).join.include?('active: true') && !unblocked
|
|
598
620
|
expect(@paint.join).to include('SESSION Investigate login and repair all expired certificates')
|
|
599
|
-
|
|
621
|
+
end
|
|
622
|
+
if listed && !unblocked
|
|
600
623
|
expect(PWN::Sessions.title_status(session_id: @pry.config.pwn_ai_session_id)[:is_summarized]).to be(false)
|
|
601
624
|
unblocked = true
|
|
602
625
|
release << true
|
|
@@ -619,7 +642,7 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
|
|
|
619
642
|
requested = false
|
|
620
643
|
selected = false
|
|
621
644
|
keys = "/sessions list\n".chars
|
|
622
|
-
|
|
645
|
+
allow(PWN::AI::Agent::Loop).to receive(:run).with(hash_including(request: 'Continue fixture', session_id: sid)) do |opts|
|
|
623
646
|
expect(PWN::Sessions.to_llm_messages(session_id: opts[:session_id]).map { |row| row[:content] }).to eq(['Prior fixture request', 'Prior fixture answer'])
|
|
624
647
|
opts[:on_tool].call('fixture callback', {}, 'fixture result')
|
|
625
648
|
'Resumed fixture complete'
|
|
@@ -848,7 +871,7 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
|
|
|
848
871
|
allow(PWN::AI::Agent::PromptBuilder).to receive(:build).and_wrap_original do |method, opts|
|
|
849
872
|
method.call(opts.merge(thin: true))
|
|
850
873
|
end
|
|
851
|
-
|
|
874
|
+
allow(PWN::AI::OpenAI).to receive(:open_ai_rest_call) do |opts|
|
|
852
875
|
expect(opts[:http_body][:messages].find { |message| message[:role] == 'system' }[:content]).to start_with("Saved role\nsecond line")
|
|
853
876
|
{ choices: [{ message: { role: 'assistant', content: 'Role forwarded.', tool_calls: [] } }] }.to_json
|
|
854
877
|
end
|
|
@@ -922,13 +945,15 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
|
|
|
922
945
|
|
|
923
946
|
it 'runs the real Loop from the public launch, steering a blocked model without a second input reader' do
|
|
924
947
|
loop_module = PWN::AI::Agent::Loop
|
|
925
|
-
entered =
|
|
948
|
+
entered = PWNTestPipeQueue.new
|
|
949
|
+
recorded = PWNTestPipeQueue.new
|
|
926
950
|
calls = []
|
|
927
951
|
Thread.current[:pwn_console_probe] = :copied
|
|
928
952
|
allow(loop_module).to receive(:call_engine) do |opts|
|
|
929
953
|
expect(Thread.current[:pwn_console_probe]).to eq(:copied)
|
|
930
954
|
expect(Thread.current[:pwn_steering_input].reader).to be_nil
|
|
931
955
|
calls << opts[:messages].map(&:dup)
|
|
956
|
+
recorded << calls.last
|
|
932
957
|
if calls.length == 1
|
|
933
958
|
entered << true
|
|
934
959
|
Queue.new.pop
|
|
@@ -949,8 +974,9 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
|
|
|
949
974
|
nil
|
|
950
975
|
end
|
|
951
976
|
end
|
|
977
|
+
calls << recorded.pop until recorded.empty?
|
|
952
978
|
expect(calls.length).to eq(2)
|
|
953
|
-
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?')
|
|
954
980
|
expect(@paint.join).to include('pwn-ai', 'ASSISTANT', 'MISSION', 'steering')
|
|
955
981
|
ensure
|
|
956
982
|
Thread.current[:pwn_console_probe] = nil
|
|
@@ -959,7 +985,7 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
|
|
|
959
985
|
it 'opens a draft-preserving swarm workspace and sends only after explicit confirmation' do
|
|
960
986
|
allow(PWN::AI::Agent::Swarm).to receive(:personas).and_return(scout: { role: 'Inspect evidence', engine: 'ollama', model: 'fixture' })
|
|
961
987
|
allow(PWN::AI::Agent::Swarm).to receive(:create).and_return(swarm_id: 'fixture')
|
|
962
|
-
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')
|
|
963
989
|
keys = "mission draft\u0007a".chars
|
|
964
990
|
stage = 0
|
|
965
991
|
launch(keys) do
|
|
@@ -979,11 +1005,11 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
|
|
|
979
1005
|
"\u0004"
|
|
980
1006
|
end
|
|
981
1007
|
end
|
|
982
|
-
expect(
|
|
1008
|
+
expect(@paint.join).to include('Workspace result')
|
|
983
1009
|
end
|
|
984
1010
|
|
|
985
1011
|
it 'cancels a blocked model on Ctrl+C, returns idle, and does not kill another worker' do
|
|
986
|
-
entered =
|
|
1012
|
+
entered = PWNTestPipeQueue.new
|
|
987
1013
|
foreign = Thread.new { sleep }
|
|
988
1014
|
allow(PWN::AI::Agent::Loop).to receive(:call_engine) {
|
|
989
1015
|
entered << true
|
|
@@ -995,7 +1021,7 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
|
|
|
995
1021
|
if !cancelled && !entered.empty?
|
|
996
1022
|
cancelled = true
|
|
997
1023
|
"\u0003"
|
|
998
|
-
elsif @paint.any? { |row| row.include?('
|
|
1024
|
+
elsif @paint.any? { |row| row.include?('Mission cancelled; all owned processes reaped') }
|
|
999
1025
|
"\u0004"
|
|
1000
1026
|
else
|
|
1001
1027
|
Thread.pass
|
|
@@ -1010,13 +1036,13 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
|
|
|
1010
1036
|
|
|
1011
1037
|
[0, 3.1].each do |startup_delay|
|
|
1012
1038
|
it "leaves a provider blocked in native IO without waiting for that read to return (startup #{startup_delay}s)" do
|
|
1013
|
-
entered =
|
|
1014
|
-
returned =
|
|
1039
|
+
entered = PWNTestPipeQueue.new
|
|
1040
|
+
returned = PWNTestPipeQueue.new
|
|
1015
1041
|
provider = nil
|
|
1016
1042
|
started = nil
|
|
1017
1043
|
allow(PWN::AI::Agent::Loop).to receive(:call_engine) do
|
|
1018
1044
|
Thread.handle_interrupt(Exception => :never) do
|
|
1019
|
-
entered <<
|
|
1045
|
+
entered << File.read('/proc/thread-self/stat').split.first
|
|
1020
1046
|
sleep 8
|
|
1021
1047
|
returned << true
|
|
1022
1048
|
end
|
|
@@ -1025,12 +1051,12 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
|
|
|
1025
1051
|
launched = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
1026
1052
|
launch(keys, startup_delay: startup_delay, render_delay: startup_delay.positive? ? 0.02 : 0) do
|
|
1027
1053
|
provider ||= entered.pop unless entered.empty?
|
|
1028
|
-
if !started && provider
|
|
1054
|
+
if !started && provider && File.read("/proc/#{provider}/status").match?(/^State:\s+S/)
|
|
1029
1055
|
# Elapsed is painted before call_engine: it is not a provider barrier.
|
|
1030
1056
|
# Time from delivered Ctrl+C, excluding startup and per-key rendering.
|
|
1031
1057
|
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
1032
1058
|
"\u0003"
|
|
1033
|
-
elsif @paint.any? { |row| row.include?('
|
|
1059
|
+
elsif @paint.any? { |row| row.include?('Mission cancelled; all owned processes reaped') }
|
|
1034
1060
|
"\u0004"
|
|
1035
1061
|
else
|
|
1036
1062
|
Thread.pass
|
|
@@ -1041,24 +1067,17 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
|
|
|
1041
1067
|
expect(started - launched).to be >= startup_delay
|
|
1042
1068
|
expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).to be < 3
|
|
1043
1069
|
expect(returned).to be_empty
|
|
1070
|
+
expect(File.exist?("/proc/#{provider}")).to be(false)
|
|
1044
1071
|
ensure
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
provider ||= entered.pop unless entered.empty?
|
|
1048
|
-
begin
|
|
1049
|
-
provider&.wakeup if provider&.alive?
|
|
1050
|
-
rescue ThreadError
|
|
1051
|
-
nil # The provider exited between alive? and wakeup.
|
|
1052
|
-
end
|
|
1053
|
-
provider&.join(2)
|
|
1054
|
-
expect(provider).not_to be_alive if provider
|
|
1072
|
+
entered&.close
|
|
1073
|
+
returned&.close
|
|
1055
1074
|
end
|
|
1056
1075
|
end
|
|
1057
1076
|
|
|
1058
1077
|
it 'forwards tool input, preserves completed evidence before steering, and locks settings while running' do
|
|
1059
|
-
entered =
|
|
1078
|
+
entered = PWNTestPipeQueue.new
|
|
1060
1079
|
calls = 0
|
|
1061
|
-
observed =
|
|
1080
|
+
observed = PWNTestPipeQueue.new
|
|
1062
1081
|
allow(PWN::AI::Agent::Loop).to receive(:call_engine) do
|
|
1063
1082
|
calls += 1
|
|
1064
1083
|
if calls == 1
|
|
@@ -1067,10 +1086,11 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
|
|
|
1067
1086
|
{ role: 'assistant', content: 'Yellow.', tool_calls: [] }
|
|
1068
1087
|
end
|
|
1069
1088
|
end
|
|
1070
|
-
|
|
1089
|
+
allow(PWN::AI::Agent::Dispatch).to receive(:call) do
|
|
1071
1090
|
entered << true
|
|
1072
|
-
|
|
1073
|
-
|
|
1091
|
+
answer = $stdin.gets
|
|
1092
|
+
observed << answer
|
|
1093
|
+
JSON.generate(success: true, result: { stdout: answer, exitstatus: 0 })
|
|
1074
1094
|
end
|
|
1075
1095
|
expect(PWN::Plugins::REPL).not_to receive(:pwn_ai_run_model)
|
|
1076
1096
|
keys = "what color is a passion fruit?\n".chars
|
|
@@ -1087,14 +1107,15 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
|
|
|
1087
1107
|
nil
|
|
1088
1108
|
end
|
|
1089
1109
|
end
|
|
1090
|
-
expect(observed).to eq("tool answer\n")
|
|
1110
|
+
expect(observed.pop).to eq("tool answer\n")
|
|
1111
|
+
expect(observed).to be_empty
|
|
1091
1112
|
expect(@paint.join).to include('TOOL', 'RESULT', 'tool answer', 'Settings and new requests wait')
|
|
1092
1113
|
expect(@paint.join).to include('tools 1')
|
|
1093
1114
|
end
|
|
1094
1115
|
|
|
1095
1116
|
it 'closes the tool prompt pipe on busy exit and joins rather than orphaning the active request' do
|
|
1096
|
-
entered =
|
|
1097
|
-
finished =
|
|
1117
|
+
entered = PWNTestPipeQueue.new
|
|
1118
|
+
finished = PWNTestPipeQueue.new
|
|
1098
1119
|
allow(PWN::AI::Agent::Loop).to receive(:call_engine).and_return(
|
|
1099
1120
|
{ role: 'assistant', tool_calls: [{ id: 'prompt', type: 'function', function: { name: 'shell', arguments: '{}' } }] }
|
|
1100
1121
|
)
|
|
@@ -1106,7 +1127,7 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
|
|
|
1106
1127
|
end
|
|
1107
1128
|
keys = "what color is a lemon?\n".chars
|
|
1108
1129
|
launch(keys) { entered.empty? ? nil : "\u0004" }
|
|
1109
|
-
expect(finished
|
|
1130
|
+
expect(finished).to be_empty # Hard cancellation does not resume the tool to finish its writes.
|
|
1110
1131
|
end
|
|
1111
1132
|
|
|
1112
1133
|
it 'wraps menu selection without changing the draft or persistent history, then recalls after Escape' do
|
|
@@ -2512,14 +2533,14 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
|
|
|
2512
2533
|
|
|
2513
2534
|
it 'repaints settings and theme changed by a command worker without worker drawing or a wakeup key' do
|
|
2514
2535
|
PWN::Env[:ai] = { active: :ollama, ollama: { model: 'settings-fixture' } }
|
|
2515
|
-
owner =
|
|
2536
|
+
owner = Process.pid
|
|
2516
2537
|
painted = []
|
|
2517
2538
|
allow_any_instance_of(PWN::Plugins::REPL::AIConsole::Console).to receive(:put).and_wrap_original do |method, *args|
|
|
2518
2539
|
painted << args
|
|
2519
2540
|
method.call(*args)
|
|
2520
2541
|
end
|
|
2521
2542
|
allow(PWN::Cron).to receive(:run) do
|
|
2522
|
-
expect(
|
|
2543
|
+
expect(Process.pid).not_to eq(owner)
|
|
2523
2544
|
slot = PWN::Env[:ai][PWN::Env[:ai][:active].to_sym]
|
|
2524
2545
|
slot[:system_role_content] = 'worker settings role'
|
|
2525
2546
|
slot[:temp] = 0.42
|
|
@@ -2571,6 +2592,10 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
|
|
|
2571
2592
|
allow(PWN::Banner).to receive(:mini_names).and_return([:falling_blocks])
|
|
2572
2593
|
allow(PWN::Plugins::REPL).to receive(:persist_ai_selection).and_return(false)
|
|
2573
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')
|
|
2574
2599
|
original = Marshal.dump(PWN::Env[:ai])
|
|
2575
2600
|
keys = "/model grok grok-4.6\n".chars
|
|
2576
2601
|
cancelled = false
|
|
@@ -2584,7 +2609,8 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
|
|
|
2584
2609
|
cancel_key
|
|
2585
2610
|
elsif cancelled
|
|
2586
2611
|
expect(Marshal.dump(PWN::Env[:ai])).to eq(original)
|
|
2587
|
-
|
|
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}"
|
|
2588
2614
|
"\u0004"
|
|
2589
2615
|
else
|
|
2590
2616
|
Thread.pass
|
|
@@ -2618,7 +2644,7 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
|
|
|
2618
2644
|
PWN::Env[:ai][:openai] = { model: 'old', reasoning_effort: 'medium' }
|
|
2619
2645
|
allow(PWN::Plugins::REPL).to receive(:persist_ai_selection).and_return(false)
|
|
2620
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' }] }])
|
|
2621
|
-
|
|
2647
|
+
allow(PWN::AI::OpenAI).to receive(:chat_with_tools).with(hash_including(reasoning_effort: 'high')) do
|
|
2622
2648
|
{ choices: [{ message: { role: 'assistant', content: 'Effort accepted.', tool_calls: [] } }] }
|
|
2623
2649
|
end
|
|
2624
2650
|
keys = "/model openai gpt-6-astra\n".chars
|
|
@@ -2643,13 +2669,22 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
|
|
|
2643
2669
|
PWN::Sessions.append(session_id: sid, role: 'user', content: 'Previous fixture question')
|
|
2644
2670
|
PWN::Sessions.append(session_id: sid, role: 'assistant', content: 'Previous fixture answer')
|
|
2645
2671
|
allow(PWN::Plugins::REPL).to receive(:persist_ai_selection).and_return(false)
|
|
2646
|
-
|
|
2672
|
+
allow(PWN::AI::OpenAI).to receive(:chat_with_tools) do |opts|
|
|
2647
2673
|
expect(PWN::Env.dig(:ai, :openai, :model)).to eq('console-fixture')
|
|
2648
2674
|
expect(opts[:messages]).to include(hash_including(role: 'user', content: 'Previous fixture question'), hash_including(role: 'assistant', content: 'Previous fixture answer'))
|
|
2649
2675
|
{ choices: [{ message: { role: 'assistant', content: 'Selected route.', tool_calls: [] } }] }
|
|
2650
2676
|
end
|
|
2651
|
-
keys = "/model openai console-fixture\n/sessions resume resumed console\
|
|
2652
|
-
|
|
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
|
|
2653
2688
|
expect(@pry.config.pwn_ai_session_id).to eq(sid)
|
|
2654
2689
|
expect(PWN::Sessions.load(session_id: sid).map { |row| row[:content] }.join).to include('Selected route.')
|
|
2655
2690
|
end
|
|
@@ -2713,9 +2748,9 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
|
|
|
2713
2748
|
end
|
|
2714
2749
|
|
|
2715
2750
|
it 'keeps network slash commands off the event thread so tool prompts can receive explicit input' do
|
|
2716
|
-
entered =
|
|
2751
|
+
entered = PWNTestPipeQueue.new
|
|
2717
2752
|
expect(PWN::AI::Agent::Loop).not_to receive(:run)
|
|
2718
|
-
|
|
2753
|
+
allow(PWN::Plugins::REPL).to receive(:pwn_ai_dispatch_slash!).with(request: '/mcp call fixture', pry: anything, raise_errors: true) do
|
|
2719
2754
|
entered << true
|
|
2720
2755
|
puts "local command received: #{$stdin.gets}"
|
|
2721
2756
|
true
|
|
@@ -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
|