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,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'timeout'
|
|
5
|
+
|
|
6
|
+
describe 'regex-ReAct mission containment' do
|
|
7
|
+
include_context 'pwn tmp sandbox'
|
|
8
|
+
|
|
9
|
+
let(:repl) { PWN::Plugins::REPL }
|
|
10
|
+
let(:request) { +'offline legacy request' }
|
|
11
|
+
let(:pry) { double('pry', config: Pry::Config.new, input: double('input', line_buffer: request)) }
|
|
12
|
+
|
|
13
|
+
before do
|
|
14
|
+
allow(Dir).to receive(:home).and_return(@tmp)
|
|
15
|
+
@work = File.join(@tmp, 'work')
|
|
16
|
+
FileUtils.mkdir_p(@work)
|
|
17
|
+
allow(repl).to receive(:ready_tty!)
|
|
18
|
+
allow(PWN::Memory).to receive(:to_context).and_return('')
|
|
19
|
+
PWN::Env[:ai] = { active: :ollama, agent: { native_tools: false }, ollama: { response_history: { id: 'before' } } }
|
|
20
|
+
pry.config.pwn_ai = true
|
|
21
|
+
pry.config.pwn_ai_agent = true
|
|
22
|
+
allow(Pry.config).to receive(:hooks).and_return(Pry::Hooks.new)
|
|
23
|
+
repl.add_hooks
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def submit
|
|
27
|
+
Pry.config.hooks.get_hook(:after_read, :pwn_ai_hook).call(request, pry)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'fails closed before calling a provider when mission containment is unavailable' do
|
|
31
|
+
allow(PWN::Plugins::AISandbox::MissionProcess).to receive(:new).and_raise(PWN::Plugins::AISandbox::NotStarted, 'enable user/PID namespaces')
|
|
32
|
+
expect(PWN::AI::Ollama).not_to receive(:chat)
|
|
33
|
+
expect { submit }.to output(%r{enable user/PID namespaces.*no uncontained retry}).to_stderr
|
|
34
|
+
expect(request).to eq('nil')
|
|
35
|
+
expect(PWN::Env.dig(:ai, :ollama, :response_history)).to eq(id: 'before')
|
|
36
|
+
expect(repl).to have_received(:ready_tty!)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'does not enter regex fallback after a failed native mission' do
|
|
40
|
+
PWN::Env[:ai][:agent][:native_tools] = true
|
|
41
|
+
allow(repl).to receive(:pwn_ai_run_steerable).and_raise(IOError, 'mission unavailable')
|
|
42
|
+
expect(repl).not_to receive(:pwn_ai_run_legacy)
|
|
43
|
+
expect(PWN::AI::Ollama).not_to receive(:chat)
|
|
44
|
+
expect { submit }.to output(/native agent loop failed/).to_stderr
|
|
45
|
+
expect(request).to eq('nil')
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'does not retry a failed legacy provider in the controller or replace committed history' do
|
|
49
|
+
marker = File.join(@tmp, 'provider-calls')
|
|
50
|
+
allow(PWN::AI::Ollama).to receive(:chat) do
|
|
51
|
+
File.open(marker, 'a') { |file| file.puts(File.read('/proc/self/status')[/^NSpid:.*$/]) }
|
|
52
|
+
raise IOError, 'offline provider failed'
|
|
53
|
+
end
|
|
54
|
+
expect { submit }.to output(/offline provider failed.*no uncontained retry/).to_stderr
|
|
55
|
+
expect(File.readlines(marker).length).to eq(1)
|
|
56
|
+
expect(File.read(marker).split.length).to be > 2
|
|
57
|
+
expect(PWN::Env.dig(:ai, :ollama, :response_history)).to eq(id: 'before')
|
|
58
|
+
expect(request).to eq('nil')
|
|
59
|
+
expect(repl).to have_received(:ready_tty!)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'runs the fake provider and Ruby block in a real mission and returns history to the controller' do
|
|
63
|
+
marker = File.join(PWN::Plugins::AISandbox.workspace, 'tool-owner')
|
|
64
|
+
diagnostic = File.join(@tmp, 'provider-observation')
|
|
65
|
+
calls = 0
|
|
66
|
+
allow(PWN::AI::Ollama).to receive(:chat) do |options|
|
|
67
|
+
calls += 1
|
|
68
|
+
File.write(diagnostic, options[:request].to_s) if calls > 1
|
|
69
|
+
text = calls == 1 ? "```ruby\nFile.write(#{marker.inspect}, File.read('/proc/self/status'))\n```" : 'offline complete'
|
|
70
|
+
{ id: 'after', choices: [{ content: text }] }
|
|
71
|
+
end
|
|
72
|
+
submit
|
|
73
|
+
expect(File).to exist(marker), File.read(diagnostic)
|
|
74
|
+
expect(File.read(marker)[/^NSpid:.*$/].split.length).to be > 2
|
|
75
|
+
expect(calls).to eq(0)
|
|
76
|
+
expect(PWN::Env.dig(:ai, :ollama, :response_history, :id)).to eq('after')
|
|
77
|
+
expect(request).to eq('nil')
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
%w[ruby bash].each do |language|
|
|
81
|
+
it "denies protected source writes through the actual legacy #{language} hook" do
|
|
82
|
+
source = File.join(@tmp, 'synthetic-source')
|
|
83
|
+
FileUtils.mkdir_p(source)
|
|
84
|
+
target = File.join(source, 'forbidden.txt')
|
|
85
|
+
original = PWN::Plugins::AISandbox.protected_roots
|
|
86
|
+
allow(PWN::Plugins::AISandbox).to receive(:protected_roots).and_return([source, *original])
|
|
87
|
+
code = language == 'ruby' ? "File.write(#{target.inspect}, 'forbidden')" : "printf forbidden > #{target.shellescape}"
|
|
88
|
+
calls = 0
|
|
89
|
+
allow(PWN::AI::Ollama).to receive(:chat) do
|
|
90
|
+
calls += 1
|
|
91
|
+
{ choices: [{ content: calls == 1 ? "```#{language}\n#{code}\n```" : 'completed' }] }
|
|
92
|
+
end
|
|
93
|
+
submit
|
|
94
|
+
expect(File).not_to exist(target)
|
|
95
|
+
expect(request).to eq('nil')
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it 'cancels regex-ReAct with a real PTY CTRL+C and completes the next submitted mission' do
|
|
100
|
+
require 'pty'
|
|
101
|
+
script = File.join(@tmp, 'regex_pty.rb')
|
|
102
|
+
File.write(script, <<~'RUBY')
|
|
103
|
+
require 'pwn'
|
|
104
|
+
require 'pry'
|
|
105
|
+
require 'ostruct'
|
|
106
|
+
require 'fileutils'
|
|
107
|
+
$stdout.sync = true
|
|
108
|
+
work = ARGV.fetch(0)
|
|
109
|
+
runtime = File.join(Dir.home, '.pwn', 'workspaces', 'runtime')
|
|
110
|
+
FileUtils.mkdir_p(runtime)
|
|
111
|
+
PWN::Env[:ai] = { active: :ollama, ollama: {} }
|
|
112
|
+
PWN::Memory.define_singleton_method(:to_context) { |*| '' }
|
|
113
|
+
PWN::AI::Ollama.define_singleton_method(:chat) do |opts|
|
|
114
|
+
if opts[:request] == 'second'
|
|
115
|
+
{ id: 'second', choices: [{ content: 'SECOND COMPLETE' }] }
|
|
116
|
+
else
|
|
117
|
+
payload = "Process.setsid; File.write(#{File.join(runtime, 'pty-ready').inspect}, File.read('/proc/self/status')); sleep 1; File.write(#{File.join(runtime, 'pty-late').inspect}, 'late'); sleep 30"
|
|
118
|
+
code = "Process.spawn(#{RbConfig.ruby.inspect}, '-e', #{payload.inspect}, out: File::NULL, err: File::NULL); sleep 30"
|
|
119
|
+
{ choices: [{ content: "```ruby\n#{code}\n```" }] }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
pi = OpenStruct.new(config: OpenStruct.new(pwn_ai_agent: true))
|
|
123
|
+
begin
|
|
124
|
+
PWN::Plugins::REPL.pwn_ai_run_legacy(request: +'first', original_request: 'first', pry: pi)
|
|
125
|
+
rescue Interrupt
|
|
126
|
+
puts 'CANCELLED READY'
|
|
127
|
+
end
|
|
128
|
+
next_request = $stdin.gets.to_s.strip
|
|
129
|
+
PWN::Plugins::REPL.pwn_ai_run_legacy(request: next_request.dup, original_request: next_request, pry: pi)
|
|
130
|
+
puts "HISTORY #{PWN::Env.dig(:ai, :ollama, :response_history, :id)}"
|
|
131
|
+
RUBY
|
|
132
|
+
output = +''
|
|
133
|
+
reader, writer, pid = PTY.spawn({ 'HOME' => @tmp }, RbConfig.ruby, '-I', File.expand_path('../../../../../lib', __dir__), script, @work)
|
|
134
|
+
drain = Thread.new do
|
|
135
|
+
loop { output << reader.readpartial(4096) }
|
|
136
|
+
rescue EOFError, Errno::EIO
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
marker = File.join(@tmp, '.pwn', 'workspaces', 'runtime', 'pty-ready')
|
|
140
|
+
Timeout.timeout(10) { sleep 0.01 until File.exist?(marker) }
|
|
141
|
+
host_pid = File.read(marker)[/^NSpid:\s+(\d+)/, 1].to_i
|
|
142
|
+
writer.write("\x03")
|
|
143
|
+
Timeout.timeout(10) { sleep 0.01 until output.include?('CANCELLED READY') }
|
|
144
|
+
expect { Process.kill(0, host_pid) }.to raise_error(Errno::ESRCH)
|
|
145
|
+
writer.write("second\n")
|
|
146
|
+
Timeout.timeout(10) { Process.wait(pid) }
|
|
147
|
+
pid = nil
|
|
148
|
+
drain.join(5)
|
|
149
|
+
expect(output).to include('SECOND COMPLETE', 'HISTORY second')
|
|
150
|
+
sleep 1.1
|
|
151
|
+
expect(File).not_to exist(File.join(@tmp, '.pwn', 'workspaces', 'runtime', 'pty-late'))
|
|
152
|
+
ensure
|
|
153
|
+
if pid
|
|
154
|
+
begin
|
|
155
|
+
Process.kill('KILL', pid)
|
|
156
|
+
rescue StandardError
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
begin
|
|
160
|
+
Process.wait(pid)
|
|
161
|
+
rescue StandardError
|
|
162
|
+
nil
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
[reader, writer].compact.each { |io| io.close unless io.closed? }
|
|
166
|
+
drain&.join(5)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
%w[ruby bash].each do |language|
|
|
170
|
+
it "kills a detached #{language} descendant on interrupt before ready, then accepts another request" do
|
|
171
|
+
marker = File.join(PWN::Plugins::AISandbox.workspace, 'detached')
|
|
172
|
+
late = File.join(PWN::Plugins::AISandbox.workspace, 'late')
|
|
173
|
+
payload = "Process.setsid; File.write(#{marker.inspect}, File.read('/proc/self/status')); sleep 1; File.write(#{late.inspect}, 'escaped'); sleep 30"
|
|
174
|
+
ruby_code = "Process.spawn(#{RbConfig.ruby.inspect}, '-e', #{payload.inspect}, out: File::NULL, err: File::NULL); sleep 30"
|
|
175
|
+
code = language == 'ruby' ? ruby_code : [RbConfig.ruby, '-e', ruby_code].shelljoin
|
|
176
|
+
allow(PWN::AI::Ollama).to receive(:chat).and_return(choices: [{ content: "```#{language}\n#{code}\n```" }])
|
|
177
|
+
worker = Thread.new do
|
|
178
|
+
submit
|
|
179
|
+
rescue Interrupt
|
|
180
|
+
:cancelled
|
|
181
|
+
end
|
|
182
|
+
Timeout.timeout(10) { sleep 0.01 until File.exist?(marker) }
|
|
183
|
+
host_pid = File.read(marker)[/^NSpid:\s+(\d+)/, 1].to_i
|
|
184
|
+
worker.raise(Interrupt)
|
|
185
|
+
expect(Timeout.timeout(10) { worker.value }).to eq(:cancelled)
|
|
186
|
+
expect(repl).to have_received(:ready_tty!)
|
|
187
|
+
expect { Process.kill(0, host_pid) }.to raise_error(Errno::ESRCH)
|
|
188
|
+
sleep 1.1
|
|
189
|
+
expect(File).not_to exist(late)
|
|
190
|
+
allow(PWN::AI::Ollama).to receive(:chat).and_return(id: 'second', choices: [{ content: 'second complete' }])
|
|
191
|
+
request.replace('second request')
|
|
192
|
+
submit
|
|
193
|
+
expect(PWN::Env.dig(:ai, :ollama, :response_history, :id)).to eq('second')
|
|
194
|
+
expect(request).to eq('nil')
|
|
195
|
+
ensure
|
|
196
|
+
worker&.raise(Interrupt) if worker&.alive?
|
|
197
|
+
worker&.join(10)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
@@ -5,6 +5,17 @@ require 'spec_helper'
|
|
|
5
5
|
describe PWN::Plugins::REPL::AISwarm::Controller do
|
|
6
6
|
include_context 'pwn tmp sandbox'
|
|
7
7
|
|
|
8
|
+
it 'routes explicit continuation retirement through the slash command without changing ordinary retire' do
|
|
9
|
+
controller = described_class.new(session_id: 'fixture')
|
|
10
|
+
allow(PWN::AI::Agent::Swarm).to receive(:create).and_return(swarm_id: 'fixture-swarm')
|
|
11
|
+
expect(PWN::AI::Agent::Swarm).to receive(:retire).with(hash_including(name: 'scout', continuation: true)).and_return(retired: true)
|
|
12
|
+
expect(PWN::AI::Agent::Swarm).to receive(:retire).with(hash_including(name: 'critic', continuation: false)).and_return(retired: true)
|
|
13
|
+
controller.execute(line: '/swarm retire scout --continuation')
|
|
14
|
+
controller.execute(line: '/swarm retire critic')
|
|
15
|
+
ensure
|
|
16
|
+
controller&.close
|
|
17
|
+
end
|
|
18
|
+
|
|
8
19
|
it 'retains cancellation evidence and forwards bounded mission options unchanged' do
|
|
9
20
|
allow(PWN::AI::Agent::Swarm).to receive(:solve) do |opts|
|
|
10
21
|
expect(opts).to include(worktree: true, rounds: 2, verification_timeout: 120, role_timeout: 45)
|
|
@@ -89,7 +100,7 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
|
|
|
89
100
|
controller = described_class.new(session_id: 'fixture')
|
|
90
101
|
controller.execute(line: "/swarm mission #{request}")
|
|
91
102
|
controller.send(:workers).each(&:join)
|
|
92
|
-
expect(controller.snapshot[:jobs].first).to include(state: 'incomplete', team: include(roles: include(protagonist
|
|
103
|
+
expect(controller.snapshot[:jobs].first).to include(state: 'incomplete', team: include(roles: include('protagonist' => 'build')))
|
|
93
104
|
ensure
|
|
94
105
|
controller&.close
|
|
95
106
|
end
|
|
@@ -120,20 +131,20 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
|
|
|
120
131
|
end
|
|
121
132
|
|
|
122
133
|
it 'owns model interrupts on the job thread and copies request-local routing' do
|
|
123
|
-
entered =
|
|
134
|
+
entered = PWNTestPipeQueue.new
|
|
124
135
|
allow(PWN::AI::Agent::Swarm).to receive(:create).and_return(swarm_id: 'fixture')
|
|
125
136
|
Thread.current[:pwn_workspace_fixture] = :copied
|
|
126
137
|
allow(PWN::AI::Agent::Swarm).to receive(:ask) do |opts|
|
|
127
138
|
control = opts[:steering]
|
|
128
|
-
entered << [control.instance_variable_get(:@owner)
|
|
139
|
+
entered << [control.instance_variable_get(:@owner) == Thread.current, Thread.current[:pwn_workspace_fixture], Thread.current[:pwn_steering_input].is_a?(PWN::Plugins::REPL::AISwarm::JobControl)]
|
|
129
140
|
{ reply: 'done' }
|
|
130
141
|
end
|
|
131
142
|
controller = described_class.new(session_id: 'fixture')
|
|
132
143
|
controller.execute(line: '/swarm dm scout inspect')
|
|
133
|
-
|
|
134
|
-
expect(
|
|
135
|
-
expect(local).to eq(
|
|
136
|
-
expect(marker).to
|
|
144
|
+
owner_matches, local, marker = entered.pop
|
|
145
|
+
expect(owner_matches).to be(true)
|
|
146
|
+
expect(local).to eq('copied')
|
|
147
|
+
expect(marker).to be(true)
|
|
137
148
|
ensure
|
|
138
149
|
controller&.close
|
|
139
150
|
Thread.current[:pwn_workspace_fixture] = nil
|
|
@@ -141,9 +152,9 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
|
|
|
141
152
|
|
|
142
153
|
it 'passes prior speakers to debate, preserves literal flag-like missions, and retains failures' do
|
|
143
154
|
allow(PWN::AI::Agent::Swarm).to receive(:create).and_return(swarm_id: 'fixture')
|
|
144
|
-
|
|
155
|
+
recorded = PWNTestPipeQueue.new
|
|
145
156
|
allow(PWN::AI::Agent::Swarm).to receive(:ask) do |opts|
|
|
146
|
-
|
|
157
|
+
recorded << opts.slice(:request, :name)
|
|
147
158
|
raise IOError, 'fixture unavailable' if opts[:name] == 'broken'
|
|
148
159
|
|
|
149
160
|
{ reply: "reply from #{opts[:name]}" }
|
|
@@ -151,6 +162,8 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
|
|
|
151
162
|
controller = described_class.new(session_id: 'fixture')
|
|
152
163
|
controller.execute(line: '/swarm debate scout,critic "--literal mission"')
|
|
153
164
|
controller.send(:workers).each(&:join)
|
|
165
|
+
calls = []
|
|
166
|
+
calls << recorded.pop until recorded.empty?
|
|
154
167
|
expect(calls.first[:request]).to eq('--literal mission')
|
|
155
168
|
expect(calls.last[:request]).to include('reply from scout', '--literal mission')
|
|
156
169
|
controller.execute(line: '/swarm dm broken inspect')
|
|
@@ -161,7 +174,7 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
|
|
|
161
174
|
end
|
|
162
175
|
|
|
163
176
|
it 'cancels only its owned blocked model and cannot switch swarms while it runs' do
|
|
164
|
-
entered =
|
|
177
|
+
entered = PWNTestPipeQueue.new
|
|
165
178
|
foreign = Thread.new { sleep }
|
|
166
179
|
allow(PWN::AI::Agent::Swarm).to receive(:create).and_return(swarm_id: 'fixture')
|
|
167
180
|
allow(PWN::AI::Agent::Swarm).to receive(:ask) do |opts|
|
|
@@ -188,12 +201,13 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
|
|
|
188
201
|
end
|
|
189
202
|
|
|
190
203
|
it 'runs, steers, and cancels a persona through the existing Swarm API' do
|
|
191
|
-
entered =
|
|
204
|
+
entered = PWNTestPipeQueue.new
|
|
192
205
|
allow(PWN::AI::Agent::Swarm).to receive(:ask) do |opts|
|
|
193
|
-
entered <<
|
|
206
|
+
entered << true
|
|
194
207
|
opts[:on_tool].call('shell', { command: 'true' }, '{"success":true}')
|
|
195
|
-
sleep 0.01
|
|
196
|
-
|
|
208
|
+
sleep 0.01 while opts[:steering].instance_variable_get(:@pending).empty?
|
|
209
|
+
entered << opts[:steering].instance_variable_get(:@pending)
|
|
210
|
+
sleep 30
|
|
197
211
|
end
|
|
198
212
|
events = Queue.new
|
|
199
213
|
controller = described_class.new(session_id: 'console-session')
|
|
@@ -201,11 +215,11 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
|
|
|
201
215
|
allow(PWN::AI::Agent::Swarm).to receive(:spawn).and_return(name: 'scout')
|
|
202
216
|
controller.execute(line: '/swarm spawn scout inspect evidence')
|
|
203
217
|
queued = controller.execute(line: '/swarm dm scout inspect the fixture', on_event: ->(type, text) { events << [type, text] })
|
|
204
|
-
|
|
218
|
+
expect(entered.pop).to be(true)
|
|
205
219
|
expect(queued[:ok]).to eq(true)
|
|
206
220
|
expect(controller.snapshot[:jobs].first[:state]).to eq('running')
|
|
207
221
|
controller.execute(line: "/swarm steer #{queued[:job_id]} stop the scan")
|
|
208
|
-
expect(
|
|
222
|
+
expect(entered.pop).to include('stop the scan')
|
|
209
223
|
controller.execute(line: "/swarm cancel #{queued[:job_id]}")
|
|
210
224
|
sleep 0.05
|
|
211
225
|
expect(controller.snapshot[:jobs].first[:state]).to eq('cancelled')
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
require 'stringio'
|
|
6
|
+
|
|
7
|
+
RSpec.describe PWN::Plugins::REPL::AIConsole::VaultEdit, 'hardware vault editing' do
|
|
8
|
+
around do |example|
|
|
9
|
+
Dir.mktmpdir('vault-edit-yubikey-') do |root|
|
|
10
|
+
@root = root
|
|
11
|
+
example.run
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
before do
|
|
16
|
+
allow(Dir).to receive(:home).and_return(@root)
|
|
17
|
+
stub_const('PWN::Plugins::YubiKey', double('synthetic YubiKey'))
|
|
18
|
+
allow(PWN::Plugins::YubiKey).to receive(:challenge_response) do |opts|
|
|
19
|
+
OpenSSL::HMAC.digest('SHA1', 'synthetic-slot-secret', opts[:challenge])
|
|
20
|
+
end
|
|
21
|
+
@path = File.join(@root, 'selected-vault')
|
|
22
|
+
@original = { ai: { active: 'ollama', ollama: { model: 'fixture-model', key: 'synthetic-private-value' } }, retained: ['fixture-value'] }
|
|
23
|
+
File.binwrite(@path, PWN::Plugins::Vault.encrypt_data(data: YAML.dump(@original), yubikey: { slot: 2, serial: '12345678' }))
|
|
24
|
+
File.chmod(0o600, @path)
|
|
25
|
+
stub_const('PWN::Env', @original.merge(driver_opts: { pwn_env_path: @path, pwn_dec_path: File.join(@root, 'absent-sidecar') }))
|
|
26
|
+
stub_const('PWN::EnvRedacted', {})
|
|
27
|
+
allow(Pry).to receive(:history).and_return(Pry::History.new(file_path: File.join(@root, 'history')))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'keeps malformed YAML and cancellation private and leaves hardware ciphertext untouched' do
|
|
31
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
|
|
32
|
+
bytes = File.binread(@path)
|
|
33
|
+
console.submit('/vault edit')
|
|
34
|
+
console.instance_variable_get(:@vault_editor).replace('synthetic-private-value: [')
|
|
35
|
+
console.handle("\u0013")
|
|
36
|
+
expect(console.instance_variable_get(:@vault_error)).to include('Invalid YAML')
|
|
37
|
+
expect(console.instance_variable_get(:@vault_error)).not_to include('synthetic-private-value')
|
|
38
|
+
console.handle("\e")
|
|
39
|
+
expect(console.instance_variable_get(:@vault_editor)).to be_nil
|
|
40
|
+
expect(console.instance_variable_get(:@timeline).inspect).not_to include('synthetic-private-value', 'Invalid YAML')
|
|
41
|
+
expect(Pry.history.to_a.join).not_to include('synthetic-private-value')
|
|
42
|
+
expect(File.binread(@path)).to eq(bytes)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it 'preserves hardware ciphertext and Env when encryption fails or is interrupted' do
|
|
46
|
+
edit = described_class.new(%w[ai ollama model])
|
|
47
|
+
bytes = File.binread(@path)
|
|
48
|
+
env = PWN::Env
|
|
49
|
+
allow(PWN::Plugins::Vault).to receive(:encrypt_data).and_raise(IOError, 'synthetic-private-value')
|
|
50
|
+
expect { edit.save('new-model') }.to raise_error(described_class::Error, /Vault not saved/)
|
|
51
|
+
expect(File.binread(@path)).to eq(bytes)
|
|
52
|
+
expect(PWN::Env).to equal(env)
|
|
53
|
+
allow(PWN::Plugins::Vault).to receive(:encrypt_data).and_raise(Interrupt)
|
|
54
|
+
expect { edit.save('new-model') }.to raise_error(Interrupt)
|
|
55
|
+
expect(File.binread(@path)).to eq(bytes)
|
|
56
|
+
expect(PWN::Env).to equal(env)
|
|
57
|
+
expect(Dir.children(@root)).to eq(['selected-vault'])
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'merges unrelated hardware edits and refuses a changed selected scope' do
|
|
61
|
+
edit = described_class.new(%w[ai ollama model])
|
|
62
|
+
described_class.new(['retained']).save("- changed\n")
|
|
63
|
+
edit.save('new-model')
|
|
64
|
+
expect(PWN::Plugins::Vault.dump(file: @path)[:retained]).to eq(['changed'])
|
|
65
|
+
bytes = File.binread(@path)
|
|
66
|
+
expect { edit.save('stale-model') }.to raise_error(described_class::Error, /scope changed/)
|
|
67
|
+
expect(File.binread(@path)).to eq(bytes)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it 'rejects a final ciphertext conflict without leaving a staging file' do
|
|
71
|
+
edit = described_class.new(%w[ai ollama model])
|
|
72
|
+
other = PWN::Plugins::Vault.encrypt_data(data: YAML.dump(@original.merge(retained: ['concurrent'])), yubikey: { slot: 2 })
|
|
73
|
+
allow(edit).to receive(:atomic_write).and_wrap_original do |method, encrypted, previous|
|
|
74
|
+
File.binwrite(@path, other)
|
|
75
|
+
method.call(encrypted, previous)
|
|
76
|
+
end
|
|
77
|
+
expect { edit.save('new-model') }.to raise_error(described_class::Error, /changed during save/)
|
|
78
|
+
expect(File.binread(@path)).to eq(other)
|
|
79
|
+
expect(Dir.children(@root)).to eq(['selected-vault'])
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it 'fails closed with a different device response and redacted open errors' do
|
|
83
|
+
bytes = File.binread(@path)
|
|
84
|
+
allow(PWN::Plugins::YubiKey).to receive(:challenge_response).and_return('x' * 20)
|
|
85
|
+
expect { described_class.new([]) }.to raise_error(described_class::Error, /Cannot open vault/)
|
|
86
|
+
expect(File.binread(@path)).to eq(bytes)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it 'opens and saves the selected hardware scope without requiring or creating a decryptor' do
|
|
90
|
+
expect(PWN::Config).not_to receive(:refresh_env)
|
|
91
|
+
expect(PWN::Plugins::AuthenticationHelper).not_to receive(:mask_password)
|
|
92
|
+
edit = described_class.new(%w[ai ollama model])
|
|
93
|
+
expect(edit.text).to include('fixture-model')
|
|
94
|
+
expect(edit.save('new-model')).to eq(:saved)
|
|
95
|
+
expect(PWN::Plugins::Vault.hardware_encrypted?(file: @path)).to be(true)
|
|
96
|
+
document = PWN::Plugins::Vault.dump(file: @path)
|
|
97
|
+
expect(document.dig(:ai, :ollama, :model)).to eq('new-model')
|
|
98
|
+
expect(document[:retained]).to eq(['fixture-value'])
|
|
99
|
+
expect(PWN::Env.dig(:ai, :ollama, :model)).to eq('new-model')
|
|
100
|
+
expect(PWN::EnvRedacted.dig(:ai, :ollama, :key)).to include('REDACTED')
|
|
101
|
+
expect(Dir.children(@root)).to eq(['selected-vault'])
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
|
|
6
|
+
RSpec.describe PWN::Plugins::REPL, 'hardware vault settings persistence' do
|
|
7
|
+
around do |example|
|
|
8
|
+
Dir.mktmpdir('ai-yubikey-') do |root|
|
|
9
|
+
@root = root
|
|
10
|
+
example.run
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
before do
|
|
15
|
+
allow(Dir).to receive(:home).and_return(@root)
|
|
16
|
+
stub_const('PWN::Plugins::YubiKey', double('synthetic YubiKey'))
|
|
17
|
+
allow(PWN::Plugins::YubiKey).to receive(:challenge_response) do |opts|
|
|
18
|
+
OpenSSL::HMAC.digest('SHA1', 'synthetic-slot-secret', opts[:challenge])
|
|
19
|
+
end
|
|
20
|
+
@path = File.join(@root, 'selected-vault')
|
|
21
|
+
@original = { ai: { active: 'ollama', ollama: { model: 'fixture-model', system_role_content: 'fixture-role' } }, retained: 'fixture-value' }
|
|
22
|
+
File.binwrite(@path, PWN::Plugins::Vault.encrypt_data(data: YAML.dump(@original), yubikey: { slot: 2 }))
|
|
23
|
+
stub_const('PWN::Env', @original.merge(driver_opts: { pwn_env_path: @path, pwn_dec_path: File.join(@root, 'absent-sidecar') }))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'preserves ciphertext and Env on encryption failure without logging private exception content' do
|
|
27
|
+
bytes = File.binread(@path)
|
|
28
|
+
env = Marshal.dump(PWN::Env)
|
|
29
|
+
allow(PWN::Plugins::Vault).to receive(:encrypt_data).and_raise(IOError, 'synthetic-private-exception')
|
|
30
|
+
expect do
|
|
31
|
+
expect(described_class.persist_ai_selection(engine: 'ollama', model: 'new-model')).to be(false)
|
|
32
|
+
end.to output(/settings persist failed: IOError/).to_stderr
|
|
33
|
+
expect(File.binread(@path)).to eq(bytes)
|
|
34
|
+
expect(Marshal.dump(PWN::Env)).to eq(env)
|
|
35
|
+
expect(Dir.children(@root)).to eq(['selected-vault'])
|
|
36
|
+
expect do
|
|
37
|
+
described_class.persist_ai_selection(engine: 'ollama', model: 'new-model')
|
|
38
|
+
end.not_to output(/synthetic-private-exception/).to_stderr
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'persists model and effort into the hardware vault without converting its format or losing other settings' do
|
|
42
|
+
expect(described_class.persist_ai_selection(engine: 'ollama', model: 'new-model', reasoning_effort: 'high')).to be(true)
|
|
43
|
+
expect(PWN::Plugins::Vault.hardware_encrypted?(file: @path)).to be(true)
|
|
44
|
+
expected = { ai: { active: 'ollama', ollama: { model: 'new-model', system_role_content: 'fixture-role', reasoning_effort: 'high' } }, retained: 'fixture-value' }
|
|
45
|
+
expect(PWN::Plugins::Vault.dump(file: @path)).to eq(expected)
|
|
46
|
+
expect(Dir.children(@root)).to eq(['selected-vault'])
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
|
|
6
|
+
RSpec.describe PWN::Plugins::REPL, 'mesh vault persistence' do
|
|
7
|
+
before do
|
|
8
|
+
@home = Dir.mktmpdir('pwn-mesh-vault-')
|
|
9
|
+
allow(Dir).to receive(:home).and_return(@home)
|
|
10
|
+
@path = File.join(@home, 'custom-vault')
|
|
11
|
+
@decryptor = File.join(@home, 'independent-decryptor')
|
|
12
|
+
stub_const('PWN::Env', { driver_opts: { pwn_env_path: @path, pwn_dec_path: @decryptor }, plugins: {} })
|
|
13
|
+
@device = double('offline YubiKey')
|
|
14
|
+
stub_const('PWN::Plugins::YubiKey', @device)
|
|
15
|
+
allow(@device).to receive(:challenge_response) do |opts|
|
|
16
|
+
expect(opts).to include(slot: 2, serial: '123456')
|
|
17
|
+
OpenSSL::HMAC.digest('SHA1', 'synthetic-slot-secret', opts.fetch(:challenge))
|
|
18
|
+
end
|
|
19
|
+
@cfg = {
|
|
20
|
+
unrelated: 'keep', plugins: {
|
|
21
|
+
meshtastic: {
|
|
22
|
+
transport: 'mqtt', channel: { active: 'LongFast', LongFast: { psk: 'fixture-psk', topic: '2/e/LongFast/#' } },
|
|
23
|
+
mqtt: { username: 'fixture-user', password: 'fixture-password' }, serial: { port: '/dev/fixture-old', baud: 115_200 }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
File.write(@path, YAML.dump(@cfg))
|
|
28
|
+
PWN::Plugins::Vault.encrypt(file: @path, yubikey: { slot: 2, serial: '123456' })
|
|
29
|
+
expect(PWN::Plugins::AuthenticationHelper).not_to receive(:mask_password)
|
|
30
|
+
allow(described_class).to receive(:mesh_ui_puts)
|
|
31
|
+
allow(ENV).to receive(:fetch).and_call_original
|
|
32
|
+
allow(ENV).to receive(:fetch).with('PWN_DECRYPTOR_KEY', nil).and_return(nil)
|
|
33
|
+
allow(ENV).to receive(:fetch).with('PWN_DECRYPTOR_IV', nil).and_return(nil)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
after { FileUtils.remove_entry(@home) }
|
|
37
|
+
|
|
38
|
+
it 'merges menu settings into a hardware vault without a decryptor or plaintext rewrite' do
|
|
39
|
+
expect(PWN::Plugins::Vault).to receive(:update).with(file: @path, decryptor_file: @decryptor).and_call_original
|
|
40
|
+
expect(PWN::Plugins::Vault).not_to receive(:decrypt)
|
|
41
|
+
expect(PWN::Plugins::Vault).not_to receive(:encrypt)
|
|
42
|
+
expect(File).not_to receive(:write)
|
|
43
|
+
mesh = { 'transport' => 'serial', 'serial' => { 'port' => '/dev/fixture-new' }, 'channel' => { 'active' => 'LongFast' } }
|
|
44
|
+
|
|
45
|
+
expect(described_class.persist_mesh_env(mesh: mesh)).to be(true)
|
|
46
|
+
persisted = PWN::Plugins::Vault.dump(file: @path)
|
|
47
|
+
expect(persisted.dig(:plugins, :meshtastic)).to eq(@cfg.dig(:plugins, :meshtastic).merge(transport: 'serial', serial: { port: '/dev/fixture-new', baud: 115_200 }))
|
|
48
|
+
expect(persisted[:unrelated]).to eq('keep')
|
|
49
|
+
expect(File.binread(@path)).to start_with(PWN::Plugins::Vault::YUBIKEY_HEADER)
|
|
50
|
+
expect(File.stat(@path).mode & 0o777).to eq(0o600)
|
|
51
|
+
expect(Dir.children(@home)).to eq(['custom-vault'])
|
|
52
|
+
expect(described_class).not_to have_received(:mesh_ui_puts)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'keeps legacy credentials and nested mesh settings while staging ciphertext only' do
|
|
56
|
+
credentials = { key: Base64.strict_encode64('k' * 32), iv: Base64.strict_encode64('i' * 16) }
|
|
57
|
+
File.write(@decryptor, YAML.dump(credentials))
|
|
58
|
+
File.chmod(0o400, @decryptor)
|
|
59
|
+
File.write(@path, YAML.dump(@cfg))
|
|
60
|
+
PWN::Plugins::Vault.encrypt({ file: @path }.merge(credentials))
|
|
61
|
+
before = File.binread(@path)
|
|
62
|
+
sidecar_before = File.binread(@decryptor)
|
|
63
|
+
expect(File).not_to receive(:write)
|
|
64
|
+
expect(PWN::Plugins::Vault).not_to receive(:decrypt)
|
|
65
|
+
expect(PWN::Plugins::Vault).not_to receive(:encrypt)
|
|
66
|
+
expect(Tempfile).to receive(:create).and_wrap_original do |create, *args, &block|
|
|
67
|
+
create.call(*args) do |temp|
|
|
68
|
+
expect(temp).to receive(:write).and_wrap_original do |write, bytes|
|
|
69
|
+
plaintext = PWN::Plugins::Vault.decrypt_data(credentials.merge(data: bytes))
|
|
70
|
+
expect(YAML.safe_load(plaintext, permitted_classes: [Symbol], symbolize_names: true).dig(:plugins, :meshtastic, :serial)).to eq(port: '/dev/fixture-new', baud: 115_200)
|
|
71
|
+
expect(bytes).not_to include('fixture-password', 'fixture-psk')
|
|
72
|
+
expect(File.binread(@path)).to eq(before)
|
|
73
|
+
expect(temp.stat.mode & 0o777).to eq(0o600)
|
|
74
|
+
write.call(bytes)
|
|
75
|
+
end
|
|
76
|
+
block.call(temp)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
expect(described_class.persist_mesh_env(mesh: { serial: { port: '/dev/fixture-new' } })).to be(true)
|
|
81
|
+
persisted = PWN::Plugins::Vault.dump({ file: @path }.merge(credentials))
|
|
82
|
+
expect(persisted.dig(:plugins, :meshtastic)).to eq(@cfg.dig(:plugins, :meshtastic).merge(serial: { port: '/dev/fixture-new', baud: 115_200 }))
|
|
83
|
+
expect(persisted[:unrelated]).to eq('keep')
|
|
84
|
+
expect(File.binread(@decryptor)).to eq(sidecar_before)
|
|
85
|
+
expect(File.stat(@decryptor).mode & 0o777).to eq(0o400)
|
|
86
|
+
expect(File.stat(@path).mode & 0o777).to eq(0o600)
|
|
87
|
+
expect(Dir.children(@home)).to match_array(%w[custom-vault independent-decryptor])
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it 'uses the default vault and live mesh settings when no overrides are supplied' do
|
|
91
|
+
default_path = File.join(@home, '.pwn', 'pwn.yaml')
|
|
92
|
+
FileUtils.mkdir_p(File.dirname(default_path))
|
|
93
|
+
FileUtils.cp(@path, default_path)
|
|
94
|
+
before = File.binread(@path)
|
|
95
|
+
PWN::Env[:driver_opts] = {}
|
|
96
|
+
PWN::Env[:plugins][:meshtastic] = { transport: 'serial' }
|
|
97
|
+
|
|
98
|
+
expect(described_class.persist_mesh_env).to be(true)
|
|
99
|
+
expect(PWN::Plugins::Vault.dump(file: default_path).dig(:plugins, :meshtastic)).to eq(@cfg.dig(:plugins, :meshtastic).merge(transport: 'serial'))
|
|
100
|
+
expect(File.binread(@path)).to eq(before)
|
|
101
|
+
expect(Dir.children(File.dirname(default_path))).to eq(['pwn.yaml'])
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it 'does not overwrite a concurrent ciphertext change' do
|
|
105
|
+
concurrent = PWN::Plugins::Vault.encrypt_data(data: YAML.dump(@cfg.merge(unrelated: 'newer')), yubikey: { slot: 2, serial: '123456' })
|
|
106
|
+
allow(PWN::Plugins::Vault).to receive(:encrypt_data).and_wrap_original do |encrypt, opts|
|
|
107
|
+
ciphertext = encrypt.call(opts)
|
|
108
|
+
File.binwrite(@path, concurrent)
|
|
109
|
+
ciphertext
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
expect(described_class.persist_mesh_env(mesh: { transport: 'serial' })).to be(false)
|
|
113
|
+
expect(File.binread(@path)).to eq(concurrent)
|
|
114
|
+
expect(Dir.children(@home)).to eq(['custom-vault'])
|
|
115
|
+
expect(described_class).to have_received(:mesh_ui_puts).with(text: '[pwn-mesh] persist skipped: IOError')
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it 'does not use a software sidecar when the hardware device is unavailable' do
|
|
119
|
+
File.write(@decryptor, YAML.dump(key: Base64.strict_encode64('k' * 32), iv: Base64.strict_encode64('i' * 16)))
|
|
120
|
+
before = File.binread(@path)
|
|
121
|
+
sidecar_before = File.binread(@decryptor)
|
|
122
|
+
allow(@device).to receive(:challenge_response).and_raise(IOError, 'synthetic-private-error')
|
|
123
|
+
expect(PWN::Plugins::Vault).not_to receive(:encrypt_data)
|
|
124
|
+
|
|
125
|
+
expect(described_class.persist_mesh_env(mesh: { transport: 'serial' })).to be(false)
|
|
126
|
+
expect(File.binread(@path)).to eq(before)
|
|
127
|
+
expect(File.binread(@decryptor)).to eq(sidecar_before)
|
|
128
|
+
expect(described_class).to have_received(:mesh_ui_puts).with(text: '[pwn-mesh] persist skipped: IOError')
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it 'leaves hardware ciphertext untouched when the device disappears during re-encryption' do
|
|
132
|
+
before = File.binread(@path)
|
|
133
|
+
calls = 0
|
|
134
|
+
allow(@device).to receive(:challenge_response) do |opts|
|
|
135
|
+
calls += 1
|
|
136
|
+
raise IOError, 'synthetic-private-error' if calls > 1
|
|
137
|
+
|
|
138
|
+
OpenSSL::HMAC.digest('SHA1', 'synthetic-slot-secret', opts.fetch(:challenge))
|
|
139
|
+
end
|
|
140
|
+
expect(PWN::Plugins::Vault).not_to receive(:decrypt)
|
|
141
|
+
expect(File).not_to receive(:write)
|
|
142
|
+
expect(Tempfile).not_to receive(:create)
|
|
143
|
+
|
|
144
|
+
expect(described_class.persist_mesh_env(mesh: { transport: 'serial' })).to be(false)
|
|
145
|
+
expect(described_class).to have_received(:mesh_ui_puts).with(text: '[pwn-mesh] persist skipped: IOError')
|
|
146
|
+
expect(File.binread(@path)).to eq(before)
|
|
147
|
+
expect(Dir.children(@home)).to eq(['custom-vault'])
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe PWN::Plugins::REPL::AIConsole::VaultEdit, 'YAML scalar fidelity' do
|
|
6
|
+
it 'preserves colon-prefixed lines inside nested mapping and array values' do
|
|
7
|
+
value = { setting: "before\n:leading-colon\nafter\n", rows: [{ text: "line\n:other: value\n" }] }
|
|
8
|
+
editor = described_class.allocate
|
|
9
|
+
expect(editor.parse(editor.serialize(value))).to eq(value)
|
|
10
|
+
end
|
|
11
|
+
end
|