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.
- checksums.yaml +4 -4
- data/.ruby-version +1 -1
- data/Gemfile +8 -8
- data/README.md +37 -0
- data/Rakefile +19 -1
- data/bin/pwn +2 -1
- data/bin/pwn_setup +1 -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 +82 -0
- data/documentation/Configuration.md +10 -6
- data/documentation/Contributing.md +1 -1
- data/documentation/Cron.md +64 -35
- data/documentation/Diagrams.md +2 -2
- data/documentation/General-PWN-Usage.md +4 -3
- data/documentation/Home.md +6 -5
- data/documentation/How-PWN-Works.md +7 -6
- data/documentation/Installation.md +254 -10
- data/documentation/Mission-Cancellation.md +134 -0
- data/documentation/Operability.md +17 -0
- data/documentation/Persistence.md +8 -6
- data/documentation/Plugin-Degradation.md +12 -0
- data/documentation/Plugins.md +3 -2
- data/documentation/Reinforcement-Learning.md +5 -6
- 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 +183 -0
- data/documentation/YubiKey.md +339 -0
- data/documentation/diagrams/cron-scheduling.svg +130 -128
- data/documentation/diagrams/dot/cron-scheduling.dot +5 -5
- data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
- data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
- data/documentation/diagrams/persistence-filesystem.svg +172 -142
- data/documentation/diagrams/plugin-ecosystem.svg +64 -64
- data/documentation/pwn-ai-Agent.md +10 -4
- data/etc/default_cron/session_cleanup.rb +1245 -22
- 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/extrospection.rb +6 -7
- data/lib/pwn/ai/agent/learning.rb +62 -25
- 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.rb +5 -0
- data/lib/pwn/ai/agent/policy_evaluation.rb +8 -8
- 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 +35 -16
- 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 +53 -3
- data/lib/pwn/ai/server_tools.rb +231 -0
- data/lib/pwn/ai.rb +1 -0
- data/lib/pwn/backups.rb +347 -0
- data/lib/pwn/config.rb +70 -39
- data/lib/pwn/cron.rb +120 -49
- data/lib/pwn/migrate.rb +150 -68
- 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/binary_parser.rb +5 -4
- data/lib/pwn/plugins/ghidra.rb +4 -3
- data/lib/pwn/plugins/google_workspace.rb +4 -21
- data/lib/pwn/plugins/jobs.rb +1 -1
- data/lib/pwn/plugins/log.rb +21 -3
- data/lib/pwn/plugins/preflight_checker.rb +34 -9
- data/lib/pwn/plugins/recon.rb +4 -1
- data/lib/pwn/plugins/repl/ai.rb +305 -27
- data/lib/pwn/plugins/repl/ai_console.rb +273 -89
- data/lib/pwn/plugins/repl/ai_swarm.rb +181 -62
- data/lib/pwn/plugins/repl/mesh.rb +3 -16
- data/lib/pwn/plugins/repl/vault.rb +4 -15
- data/lib/pwn/plugins/repl.rb +1 -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/sessions.rb +58 -16
- data/lib/pwn/setup.rb +123 -41
- 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/scripts/build_gate.rb +58 -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/build_gate_spec.rb +73 -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/persistence_roundtrip_spec.rb +1 -7
- 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_spec.rb +27 -0
- 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 +6 -9
- 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 +31 -3
- data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
- data/spec/lib/pwn/backups_spec.rb +114 -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/cron_spec.rb +113 -6
- data/spec/lib/pwn/migrate_spec.rb +108 -5
- 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_spec.rb +7 -0
- 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 +125 -78
- data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
- data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +30 -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_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_spec.rb +13 -10
- data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
- data/spec/lib/pwn/session_cleanup_concurrency_spec.rb +201 -0
- data/spec/lib/pwn/session_cleanup_default_spec.rb +167 -2
- data/spec/lib/pwn/session_cleanup_legacy_spec.rb +101 -0
- data/spec/lib/pwn/session_cleanup_payload_spec.rb +335 -2
- data/spec/lib/pwn/session_cleanup_recovery_spec.rb +403 -0
- data/spec/lib/pwn/sessions_spec.rb +112 -0
- data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
- data/spec/lib/pwn/setup_spec.rb +3 -1
- 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/gate_failure_formatter.rb +14 -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 +599 -15
- 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
|
|
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
|
|
@@ -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 '
|
|
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 =
|
|
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 }
|
|
341
365
|
worker = console.instance_variable_get(:@worker)
|
|
342
|
-
expect(worker.join(
|
|
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 =
|
|
381
|
-
release =
|
|
382
|
-
|
|
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(
|
|
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 =
|
|
553
|
-
release =
|
|
554
|
-
owner =
|
|
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
|
-
|
|
559
|
-
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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 =
|
|
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?('
|
|
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 =
|
|
1002
|
-
returned =
|
|
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 <<
|
|
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
|
|
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?('
|
|
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
|
-
|
|
1034
|
-
|
|
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 =
|
|
1078
|
+
entered = PWNTestPipeQueue.new
|
|
1048
1079
|
calls = 0
|
|
1049
|
-
observed =
|
|
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
|
-
|
|
1089
|
+
allow(PWN::AI::Agent::Dispatch).to receive(:call) do
|
|
1059
1090
|
entered << true
|
|
1060
|
-
|
|
1061
|
-
|
|
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 =
|
|
1085
|
-
finished =
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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\
|
|
2640
|
-
|
|
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 =
|
|
2751
|
+
entered = PWNTestPipeQueue.new
|
|
2705
2752
|
expect(PWN::AI::Agent::Loop).not_to receive(:run)
|
|
2706
|
-
|
|
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
|