pwn 0.5.784 → 0.5.787
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.ruby-version +1 -1
- data/Gemfile +6 -6
- data/README.md +37 -0
- data/bin/pwn +2 -1
- data/bin/pwn_web_cache_deception +35 -7
- data/build_gem.sh +1 -1
- data/documentation/AI-Integration.md +7 -5
- data/documentation/Agent-Tool-Registry.md +2 -2
- data/documentation/Backup-Retention.md +69 -15
- data/documentation/Configuration.md +10 -6
- data/documentation/Contributing.md +1 -1
- data/documentation/Cron.md +2 -2
- data/documentation/Diagrams.md +2 -2
- data/documentation/General-PWN-Usage.md +4 -3
- data/documentation/Harness-Evaluation.md +1 -1
- data/documentation/Home.md +6 -5
- data/documentation/How-PWN-Works.md +6 -5
- data/documentation/Installation.md +12 -9
- data/documentation/Mission-Cancellation.md +134 -0
- data/documentation/Operability.md +18 -0
- data/documentation/Persistence.md +7 -5
- data/documentation/Plugin-Degradation.md +14 -0
- data/documentation/Plugins.md +3 -2
- data/documentation/Policy-Benchmark.md +32 -33
- data/documentation/Runtime-Source-Protection.md +162 -0
- data/documentation/Transparent-Browser.md +11 -0
- data/documentation/Troubleshooting.md +3 -2
- data/documentation/What-is-PWN.md +2 -2
- data/documentation/YubiKey-Vaults.md +185 -0
- data/documentation/YubiKey.md +218 -0
- data/documentation/diagrams/cron-scheduling.svg +130 -130
- data/documentation/diagrams/dot/cron-scheduling.dot +3 -3
- data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
- data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
- data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +1 -1
- data/documentation/diagrams/dot/reinforcement-learning.dot +2 -2
- data/documentation/diagrams/persistence-filesystem.svg +172 -142
- data/documentation/diagrams/plugin-ecosystem.svg +64 -64
- data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +1 -1
- data/documentation/diagrams/reinforcement-learning.svg +89 -89
- data/documentation/pwn-ai-Agent.md +10 -4
- data/etc/default_cron/session_cleanup.rb +43 -27
- data/etc/default_skills/pwn/ai/agent/mission_namespace/SKILL.md +21 -0
- data/etc/default_skills/pwn/ai/agent/tools/browser_act/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +27 -26
- data/etc/default_skills/pwn/ai/agent/tools/recon_run/SKILL.md +25 -15
- data/etc/default_skills/pwn/ai/agent/tools/workspace_tool/SKILL.md +24 -0
- data/etc/default_skills/pwn/ai/server_tools/SKILL.md +25 -0
- data/etc/default_skills/pwn/backups/SKILL.md +54 -0
- data/etc/default_skills/pwn/plugins/recon/SKILL.md +12 -1
- data/etc/default_skills/pwn/plugins/yubikey/SKILL.md +157 -0
- data/etc/default_skills/pwn/plugins/yubikey/fido/SKILL.md +189 -0
- data/etc/default_skills/pwn/plugins/yubikey/hsmauth/SKILL.md +83 -0
- data/etc/default_skills/pwn/plugins/yubikey/management/SKILL.md +80 -0
- data/etc/default_skills/pwn/plugins/yubikey/oath/SKILL.md +115 -0
- data/etc/default_skills/pwn/plugins/yubikey/openpgp/SKILL.md +144 -0
- data/etc/default_skills/pwn/plugins/yubikey/otp/SKILL.md +97 -0
- data/etc/default_skills/pwn/plugins/yubikey/piv/SKILL.md +110 -0
- data/etc/default_skills/pwn/plugins/yubikey/protocol/SKILL.md +21 -0
- data/etc/default_skills/pwn/plugins/yubikey/security_domain/SKILL.md +75 -0
- data/etc/default_skills/pwn/plugins/yubikey/security_domain/scp/SKILL.md +46 -0
- data/etc/default_skills/pwn/plugins/yubikey/transport/SKILL.md +98 -0
- data/etc/default_skills/pwn/redaction/SKILL.md +13 -1
- data/lib/pwn/ai/agent/dispatch.rb +26 -15
- data/lib/pwn/ai/agent/learning.rb +14 -18
- data/lib/pwn/ai/agent/loop.rb +17 -1
- data/lib/pwn/ai/agent/mission_namespace.rb +331 -0
- data/lib/pwn/ai/agent/policy_evaluation.rb +334 -36
- data/lib/pwn/ai/agent/prompt_builder.rb +13 -2
- data/lib/pwn/ai/agent/request_runtime.rb +122 -18
- data/lib/pwn/ai/agent/reward.rb +5 -3
- data/lib/pwn/ai/agent/skill_consolidation.rb +3 -2
- data/lib/pwn/ai/agent/skill_review.rb +9 -5
- data/lib/pwn/ai/agent/swarm.rb +4 -0
- data/lib/pwn/ai/agent/tools/browser_act.rb +32 -0
- data/lib/pwn/ai/agent/tools/loot_query.rb +11 -1
- data/lib/pwn/ai/agent/tools/recon_run.rb +11 -1
- data/lib/pwn/ai/agent/tools/workspace_tool.rb +29 -0
- data/lib/pwn/ai/agent/turn_finalizer.rb +6 -2
- data/lib/pwn/ai/agent.rb +1 -0
- data/lib/pwn/ai/anthropic.rb +8 -33
- data/lib/pwn/ai/cli.rb +34 -5
- data/lib/pwn/ai/gemini.rb +1 -0
- data/lib/pwn/ai/grok.rb +39 -28
- data/lib/pwn/ai/ollama.rb +4 -2
- data/lib/pwn/ai/open_ai.rb +11 -32
- data/lib/pwn/ai/open_web_ui.rb +62 -4
- data/lib/pwn/ai/router.rb +8 -1
- data/lib/pwn/ai/server_tools.rb +231 -0
- data/lib/pwn/ai.rb +1 -0
- data/lib/pwn/backups.rb +349 -0
- data/lib/pwn/config.rb +70 -39
- data/lib/pwn/migrate.rb +85 -280
- data/lib/pwn/module_skills.rb +7 -0
- data/lib/pwn/plugins/ai_sandbox.rb +538 -1
- data/lib/pwn/plugins/authentication_helper.rb +74 -4
- data/lib/pwn/plugins/basic_auth.rb +2 -1
- data/lib/pwn/plugins/google_workspace.rb +4 -21
- data/lib/pwn/plugins/jobs.rb +1 -1
- data/lib/pwn/plugins/log.rb +3 -1
- data/lib/pwn/plugins/preflight_checker.rb +34 -9
- data/lib/pwn/plugins/recon.rb +4 -1
- data/lib/pwn/plugins/repl/ai.rb +293 -26
- data/lib/pwn/plugins/repl/ai_console.rb +268 -88
- data/lib/pwn/plugins/repl/ai_swarm.rb +177 -58
- data/lib/pwn/plugins/repl/mesh.rb +14 -19
- data/lib/pwn/plugins/repl/vault.rb +4 -15
- data/lib/pwn/plugins/repl.rb +9 -216
- data/lib/pwn/plugins/sandbox/driver.rb +4 -0
- data/lib/pwn/plugins/transparent_browser.rb +215 -0
- data/lib/pwn/plugins/vault.rb +412 -135
- data/lib/pwn/plugins/yubikey/fido.rb +1288 -0
- data/lib/pwn/plugins/yubikey/hsmauth.rb +439 -0
- data/lib/pwn/plugins/yubikey/management.rb +686 -0
- data/lib/pwn/plugins/yubikey/oath.rb +766 -0
- data/lib/pwn/plugins/yubikey/openpgp.rb +1063 -0
- data/lib/pwn/plugins/yubikey/otp.rb +897 -0
- data/lib/pwn/plugins/yubikey/piv.rb +1315 -0
- data/lib/pwn/plugins/yubikey/protocol.rb +341 -0
- data/lib/pwn/plugins/yubikey/security_domain/scp.rb +482 -0
- data/lib/pwn/plugins/yubikey/security_domain.rb +464 -0
- data/lib/pwn/plugins/yubikey/transport.rb +1017 -0
- data/lib/pwn/plugins/yubikey.rb +844 -0
- data/lib/pwn/plugins.rb +1 -0
- data/lib/pwn/redaction.rb +69 -12
- data/lib/pwn/session_trace.rb +4 -0
- data/lib/pwn/setup.rb +114 -24
- data/lib/pwn/version.rb +1 -1
- data/lib/pwn/www/app_cobalt_io.rb +16 -5
- data/lib/pwn/www/bug_crowd.rb +16 -5
- data/lib/pwn/www/coinbase_pro.rb +16 -5
- data/lib/pwn/www/github.rb +30 -14
- data/lib/pwn/www/paypal.rb +15 -4
- data/lib/pwn/www/synack.rb +16 -5
- data/lib/pwn/www/twitter.rb +17 -6
- data/lib/pwn.rb +1 -0
- data/spec/documentation/installation_md_spec.rb +1 -1
- data/spec/fixtures/yubikey/apdu_frames.json +162 -0
- data/spec/integration/authentication_helper_oath_spec.rb +117 -0
- data/spec/integration/controller_spill_spec.rb +48 -0
- data/spec/integration/harness_process_spec.rb +97 -0
- data/spec/integration/mission_cancel_spec.rb +79 -0
- data/spec/integration/mission_curses_pty_spec.rb +44 -0
- data/spec/integration/readme_security_spec.rb +47 -0
- data/spec/integration/web_cache_deception_yubikey_spec.rb +159 -0
- data/spec/lib/pwn/ai/agent/dispatch_spec.rb +6 -3
- data/spec/lib/pwn/ai/agent/dispatch_vault_safety_spec.rb +86 -0
- data/spec/lib/pwn/ai/agent/injection_guard_spec.rb +2 -0
- data/spec/lib/pwn/ai/agent/learning_spec.rb +4 -4
- data/spec/lib/pwn/ai/agent/loop_artifacts_spec.rb +5 -0
- data/spec/lib/pwn/ai/agent/loop_spec.rb +104 -29
- data/spec/lib/pwn/ai/agent/mission_namespace_spec.rb +138 -0
- data/spec/lib/pwn/ai/agent/policy_evaluation_spec.rb +10 -23
- data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +48 -0
- data/spec/lib/pwn/ai/agent/shell_search_effect_spec.rb +44 -0
- data/spec/lib/pwn/ai/agent/solve_spec.rb +6 -4
- data/spec/lib/pwn/ai/agent/tools/browser_act_spec.rb +44 -0
- data/spec/lib/pwn/ai/agent/tools/loot_query_yubikey_spec.rb +55 -0
- data/spec/lib/pwn/ai/agent/tools/recon_run_yubikey_spec.rb +43 -0
- data/spec/lib/pwn/ai/agent/tools/workspace_tool_spec.rb +20 -0
- data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +7 -0
- data/spec/lib/pwn/ai/anthropic_vault_enrollment_spec.rb +37 -0
- data/spec/lib/pwn/ai/cli_spec.rb +8 -16
- data/spec/lib/pwn/ai/oauth_vault_persistence_spec.rb +195 -0
- data/spec/lib/pwn/ai/open_ai_spec.rb +2 -2
- data/spec/lib/pwn/ai/open_web_ui_workspace_spec.rb +36 -0
- data/spec/lib/pwn/ai/router_spec.rb +1 -1
- data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
- data/spec/lib/pwn/backups_spec.rb +129 -0
- data/spec/lib/pwn/config_redaction_yubikey_spec.rb +28 -0
- data/spec/lib/pwn/config_yubikey_spec.rb +92 -0
- data/spec/lib/pwn/migrate_spec.rb +29 -81
- data/spec/lib/pwn/migrate_yubikey_spec.rb +128 -0
- data/spec/lib/pwn/module_skills_spec.rb +8 -0
- data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +230 -0
- data/spec/lib/pwn/plugins/authentication_helper_native_yubikey_spec.rb +40 -0
- data/spec/lib/pwn/plugins/authentication_helper_yubikey_spec.rb +90 -0
- data/spec/lib/pwn/plugins/capability_broker/daemon_spec.rb +1 -1
- data/spec/lib/pwn/plugins/google_workspace_spec.rb +17 -35
- data/spec/lib/pwn/plugins/log_yubikey_spec.rb +47 -0
- data/spec/lib/pwn/plugins/preflight_checker_native_yubikey_spec.rb +19 -0
- data/spec/lib/pwn/plugins/preflight_checker_yubikey_spec.rb +55 -0
- data/spec/lib/pwn/plugins/recon_yubikey_spec.rb +43 -0
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +110 -75
- data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
- data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +19 -16
- data/spec/lib/pwn/plugins/repl/ai_vault_edit_yubikey_spec.rb +103 -0
- data/spec/lib/pwn/plugins/repl/ai_yubikey_spec.rb +48 -0
- data/spec/lib/pwn/plugins/repl/mesh_vault_persistence_spec.rb +149 -0
- data/spec/lib/pwn/plugins/repl/vault_yaml_serialization_spec.rb +11 -0
- data/spec/lib/pwn/plugins/repl/vault_yubikey_spec.rb +39 -0
- data/spec/lib/pwn/plugins/repl_mesh_layout_spec.rb +2 -0
- data/spec/lib/pwn/plugins/repl_spec.rb +56 -5
- data/spec/lib/pwn/plugins/transparent_browser_jev_spec.rb +46 -0
- data/spec/lib/pwn/plugins/vault_native_yubikey_spec.rb +29 -0
- data/spec/lib/pwn/plugins/vault_publication_safety_spec.rb +258 -0
- data/spec/lib/pwn/plugins/vault_store_safety_spec.rb +241 -0
- data/spec/lib/pwn/plugins/vault_yubikey_safety_spec.rb +168 -0
- data/spec/lib/pwn/plugins/vault_yubikey_spec.rb +128 -0
- data/spec/lib/pwn/plugins/vault_yubikey_vector_spec.rb +45 -0
- data/spec/lib/pwn/plugins/yubikey/fido_spec.rb +854 -0
- data/spec/lib/pwn/plugins/yubikey/hsmauth_spec.rb +235 -0
- data/spec/lib/pwn/plugins/yubikey/management_spec.rb +277 -0
- data/spec/lib/pwn/plugins/yubikey/oath_spec.rb +349 -0
- data/spec/lib/pwn/plugins/yubikey/openpgp_spec.rb +810 -0
- data/spec/lib/pwn/plugins/yubikey/otp_spec.rb +337 -0
- data/spec/lib/pwn/plugins/yubikey/piv_spec.rb +646 -0
- data/spec/lib/pwn/plugins/yubikey/protocol_secure_spec.rb +28 -0
- data/spec/lib/pwn/plugins/yubikey/protocol_spec.rb +149 -0
- data/spec/lib/pwn/plugins/yubikey/protocol_vectors_spec.rb +18 -0
- data/spec/lib/pwn/plugins/yubikey/security_domain/scp_spec.rb +256 -0
- data/spec/lib/pwn/plugins/yubikey/security_domain/support.rb +153 -0
- data/spec/lib/pwn/plugins/yubikey/security_domain_spec.rb +296 -0
- data/spec/lib/pwn/plugins/yubikey/transport_spec.rb +760 -0
- data/spec/lib/pwn/plugins/yubikey_security_spec.rb +260 -0
- data/spec/lib/pwn/plugins/yubikey_spec.rb +209 -0
- data/spec/lib/pwn/plugins/yubikey_terminal_spec.rb +147 -0
- data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
- data/spec/lib/pwn/session_cleanup_default_spec.rb +7 -5
- data/spec/lib/pwn/session_cleanup_payload_spec.rb +11 -11
- data/spec/lib/pwn/session_cleanup_recovery_spec.rb +34 -20
- data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
- data/spec/lib/pwn/setup_vault_yubikey_spec.rb +61 -0
- data/spec/lib/pwn/setup_yubikey_spec.rb +40 -0
- data/spec/lib/pwn/www/login_yubikey_spec.rb +273 -0
- data/spec/spec_helper.rb +15 -0
- data/spec/support/harness/cli_faults.rb +13 -9
- data/spec/support/harness/controller_spill.rb +26 -0
- data/spec/support/harness/controller_state.rb +54 -0
- data/spec/support/harness/evaluate.rb +12 -1
- data/spec/support/harness/experiments.rb +1 -1
- data/spec/support/harness/fake_provider.rb +22 -6
- data/spec/support/harness/fault_preload.rb +3 -1
- data/spec/support/harness/mission_console.rb +54 -0
- data/spec/support/harness/mission_curses_pty.rb +41 -0
- data/spec/support/harness/mission_dispatch.rb +30 -0
- data/spec/support/harness/mission_fixture_preload.rb +24 -0
- data/spec/support/harness/mission_job_lifetime.rb +25 -0
- data/spec/support/harness/mission_legacy.rb +25 -0
- data/spec/support/harness/mission_normal_exit.rb +34 -0
- data/spec/support/harness/mission_swarm.rb +23 -0
- data/spec/support/harness/mission_unsupported.rb +29 -0
- data/spec/support/harness/package_acceptance.rb +33 -5
- data/spec/support/harness/session_acceptance.rb +4 -4
- data/spec/support/harness/swarm_acceptance.rb +1 -1
- data/spec/support/harness/swarm_control.rb +44 -0
- data/spec/support/mission_namespace.rb +14 -0
- data/spec/support/mission_pipe_queue.rb +33 -0
- data/third_party/pwn_rdoc.jsonl +605 -19
- metadata +129 -16
- data/scripts/benchmark_policy.rb +0 -376
- data/scripts/build_gate.rb +0 -58
- data/spec/integration/build_gate_spec.rb +0 -73
|
@@ -100,7 +100,7 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
|
|
|
100
100
|
controller = described_class.new(session_id: 'fixture')
|
|
101
101
|
controller.execute(line: "/swarm mission #{request}")
|
|
102
102
|
controller.send(:workers).each(&:join)
|
|
103
|
-
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')))
|
|
104
104
|
ensure
|
|
105
105
|
controller&.close
|
|
106
106
|
end
|
|
@@ -131,20 +131,20 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
|
|
|
131
131
|
end
|
|
132
132
|
|
|
133
133
|
it 'owns model interrupts on the job thread and copies request-local routing' do
|
|
134
|
-
entered =
|
|
134
|
+
entered = PWNTestPipeQueue.new
|
|
135
135
|
allow(PWN::AI::Agent::Swarm).to receive(:create).and_return(swarm_id: 'fixture')
|
|
136
136
|
Thread.current[:pwn_workspace_fixture] = :copied
|
|
137
137
|
allow(PWN::AI::Agent::Swarm).to receive(:ask) do |opts|
|
|
138
138
|
control = opts[:steering]
|
|
139
|
-
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)]
|
|
140
140
|
{ reply: 'done' }
|
|
141
141
|
end
|
|
142
142
|
controller = described_class.new(session_id: 'fixture')
|
|
143
143
|
controller.execute(line: '/swarm dm scout inspect')
|
|
144
|
-
|
|
145
|
-
expect(
|
|
146
|
-
expect(local).to eq(
|
|
147
|
-
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)
|
|
148
148
|
ensure
|
|
149
149
|
controller&.close
|
|
150
150
|
Thread.current[:pwn_workspace_fixture] = nil
|
|
@@ -152,9 +152,9 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
|
|
|
152
152
|
|
|
153
153
|
it 'passes prior speakers to debate, preserves literal flag-like missions, and retains failures' do
|
|
154
154
|
allow(PWN::AI::Agent::Swarm).to receive(:create).and_return(swarm_id: 'fixture')
|
|
155
|
-
|
|
155
|
+
recorded = PWNTestPipeQueue.new
|
|
156
156
|
allow(PWN::AI::Agent::Swarm).to receive(:ask) do |opts|
|
|
157
|
-
|
|
157
|
+
recorded << opts.slice(:request, :name)
|
|
158
158
|
raise IOError, 'fixture unavailable' if opts[:name] == 'broken'
|
|
159
159
|
|
|
160
160
|
{ reply: "reply from #{opts[:name]}" }
|
|
@@ -162,6 +162,8 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
|
|
|
162
162
|
controller = described_class.new(session_id: 'fixture')
|
|
163
163
|
controller.execute(line: '/swarm debate scout,critic "--literal mission"')
|
|
164
164
|
controller.send(:workers).each(&:join)
|
|
165
|
+
calls = []
|
|
166
|
+
calls << recorded.pop until recorded.empty?
|
|
165
167
|
expect(calls.first[:request]).to eq('--literal mission')
|
|
166
168
|
expect(calls.last[:request]).to include('reply from scout', '--literal mission')
|
|
167
169
|
controller.execute(line: '/swarm dm broken inspect')
|
|
@@ -172,7 +174,7 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
|
|
|
172
174
|
end
|
|
173
175
|
|
|
174
176
|
it 'cancels only its owned blocked model and cannot switch swarms while it runs' do
|
|
175
|
-
entered =
|
|
177
|
+
entered = PWNTestPipeQueue.new
|
|
176
178
|
foreign = Thread.new { sleep }
|
|
177
179
|
allow(PWN::AI::Agent::Swarm).to receive(:create).and_return(swarm_id: 'fixture')
|
|
178
180
|
allow(PWN::AI::Agent::Swarm).to receive(:ask) do |opts|
|
|
@@ -199,12 +201,13 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
|
|
|
199
201
|
end
|
|
200
202
|
|
|
201
203
|
it 'runs, steers, and cancels a persona through the existing Swarm API' do
|
|
202
|
-
entered =
|
|
204
|
+
entered = PWNTestPipeQueue.new
|
|
203
205
|
allow(PWN::AI::Agent::Swarm).to receive(:ask) do |opts|
|
|
204
|
-
entered <<
|
|
206
|
+
entered << true
|
|
205
207
|
opts[:on_tool].call('shell', { command: 'true' }, '{"success":true}')
|
|
206
|
-
sleep 0.01
|
|
207
|
-
|
|
208
|
+
sleep 0.01 while opts[:steering].instance_variable_get(:@pending).empty?
|
|
209
|
+
entered << opts[:steering].instance_variable_get(:@pending)
|
|
210
|
+
sleep 30
|
|
208
211
|
end
|
|
209
212
|
events = Queue.new
|
|
210
213
|
controller = described_class.new(session_id: 'console-session')
|
|
@@ -212,11 +215,11 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
|
|
|
212
215
|
allow(PWN::AI::Agent::Swarm).to receive(:spawn).and_return(name: 'scout')
|
|
213
216
|
controller.execute(line: '/swarm spawn scout inspect evidence')
|
|
214
217
|
queued = controller.execute(line: '/swarm dm scout inspect the fixture', on_event: ->(type, text) { events << [type, text] })
|
|
215
|
-
|
|
218
|
+
expect(entered.pop).to be(true)
|
|
216
219
|
expect(queued[:ok]).to eq(true)
|
|
217
220
|
expect(controller.snapshot[:jobs].first[:state]).to eq('running')
|
|
218
221
|
controller.execute(line: "/swarm steer #{queued[:job_id]} stop the scan")
|
|
219
|
-
expect(
|
|
222
|
+
expect(entered.pop).to include('stop the scan')
|
|
220
223
|
controller.execute(line: "/swarm cancel #{queued[:job_id]}")
|
|
221
224
|
sleep 0.05
|
|
222
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
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
require 'stringio'
|
|
6
|
+
|
|
7
|
+
RSpec.describe PWN::Plugins::REPL::Vault, 'hardware pwn-vault command' do
|
|
8
|
+
around do |example|
|
|
9
|
+
Dir.mktmpdir('repl-vault-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' } } }
|
|
23
|
+
File.binwrite(@path, PWN::Plugins::Vault.encrypt_data(data: YAML.dump(original), yubikey: { slot: 2 }))
|
|
24
|
+
stub_const('PWN::Env', { driver_opts: { pwn_env_path: @path } })
|
|
25
|
+
described_class.add_commands
|
|
26
|
+
@command = Pry::Commands.find_command('pwn-vault').new(pry_instance: Pry.new, output: StringIO.new)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'edits and refreshes a hardware vault without requiring a sidecar or passing software keys' do
|
|
30
|
+
expect(PWN::Plugins::Vault).to receive(:edit).with({ file: @path, yubikey: { slot: 2 } }) do |opts|
|
|
31
|
+
PWN::Plugins::Vault.update(opts) { |cfg| cfg[:ai][:ollama][:model] = 'new-model' }
|
|
32
|
+
end
|
|
33
|
+
expect(PWN::Config).to receive(:refresh_env).with({ pwn_env_path: @path, pwn_dec_path: "#{@path}.decryptor", yubikey: { slot: 2 } })
|
|
34
|
+
@command.process
|
|
35
|
+
expect(PWN::Plugins::Vault.dump(file: @path).dig(:ai, :ollama, :model)).to eq('new-model')
|
|
36
|
+
expect(PWN::Plugins::Vault.hardware_encrypted?(file: @path)).to be(true)
|
|
37
|
+
expect(Dir.children(@root)).to eq(['selected-vault'])
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -118,6 +118,8 @@ describe PWN::Plugins::REPL, 'mesh terminal layout' do
|
|
|
118
118
|
allow(Curses).to receive(:stdscr).and_return(double('stdscr', erase: nil, noutrefresh: nil))
|
|
119
119
|
allow(Curses).to receive(:doupdate)
|
|
120
120
|
expect { described_class.send(:mesh_relayout!, lines: 24, cols: 100) }.not_to raise_error
|
|
121
|
+
expect(PWN.const_defined?(:MeshSndrWin)).to be(false)
|
|
122
|
+
expect(PWN.const_defined?(:MeshRcvrWin)).to be(false)
|
|
121
123
|
expect(PWN::MeshTxWin).to equal(old)
|
|
122
124
|
expect(old).not_to have_received(:close)
|
|
123
125
|
ensure
|
|
@@ -17,7 +17,14 @@ describe PWN::Plugins::REPL do # rubocop:disable Metrics/BlockLength
|
|
|
17
17
|
File.write("#{path}.decryptor", YAML.dump(key: key, iv: iv))
|
|
18
18
|
PWN::Plugins::Vault.encrypt(file: path, key: key, iv: iv)
|
|
19
19
|
PWN::Env[:driver_opts] = { pwn_env_path: path, pwn_dec_path: "#{path}.decryptor" }
|
|
20
|
+
expect(PWN::Plugins::Vault).not_to receive(:decrypt)
|
|
21
|
+
allow(File).to receive(:write).and_wrap_original do |method, target, bytes, *args|
|
|
22
|
+
expect(bytes.to_s).not_to include('new role')
|
|
23
|
+
method.call(target, bytes, *args)
|
|
24
|
+
end
|
|
20
25
|
expect(described_class.pwn_ai_apply_system_role(engine: 'openai', content: "new role\nsecond line")).to eq(:saved)
|
|
26
|
+
RSpec::Mocks.space.proxy_for(PWN::Plugins::Vault).reset
|
|
27
|
+
RSpec::Mocks.space.proxy_for(File).reset
|
|
21
28
|
expect(PWN::Env.dig(:ai, :openai, :system_role_content)).to eq("new role\nsecond line")
|
|
22
29
|
expect(File.read(path)).not_to include('new role')
|
|
23
30
|
PWN::Plugins::Vault.decrypt(file: path, key: key, iv: iv)
|
|
@@ -42,7 +49,11 @@ describe PWN::Plugins::REPL do # rubocop:disable Metrics/BlockLength
|
|
|
42
49
|
PWN::Plugins::Vault.encrypt(file: path, key: key, iv: iv)
|
|
43
50
|
ciphertext = File.binread(path)
|
|
44
51
|
PWN::Env[:driver_opts] = { pwn_env_path: path, pwn_dec_path: "#{path}.decryptor" }
|
|
45
|
-
allow(
|
|
52
|
+
allow(OpenSSL::Cipher).to receive(:new).and_wrap_original do |method, *args|
|
|
53
|
+
cipher = method.call(*args)
|
|
54
|
+
allow(cipher).to receive(:encrypt).and_raise(IOError, 'fixture encryption failure')
|
|
55
|
+
cipher
|
|
56
|
+
end
|
|
46
57
|
expect { described_class.pwn_ai_apply_system_role(engine: 'openai', content: 'unsaved') }.to raise_error(/not saved/)
|
|
47
58
|
expect(PWN::Env.dig(:ai, :openai, :system_role_content)).to eq('original')
|
|
48
59
|
expect(File.binread(path)).to eq(ciphertext)
|
|
@@ -51,6 +62,47 @@ describe PWN::Plugins::REPL do # rubocop:disable Metrics/BlockLength
|
|
|
51
62
|
PWN::Env[:driver_opts] = previous_driver_opts
|
|
52
63
|
end
|
|
53
64
|
|
|
65
|
+
it 'leaves no plaintext staging bytes when killed at encryption startup' do
|
|
66
|
+
previous_driver_opts = PWN::Env[:driver_opts]
|
|
67
|
+
path = File.join(@tmp, 'kill-model.yaml')
|
|
68
|
+
key = Base64.strict_encode64('k' * 32)
|
|
69
|
+
iv = Base64.strict_encode64('i' * 16)
|
|
70
|
+
File.write(path, YAML.dump(ai: { active: 'openai' }, fixture: 'PRIVATE_FIXTURE_SENTINEL'))
|
|
71
|
+
File.write("#{path}.decryptor", YAML.dump(key: key, iv: iv))
|
|
72
|
+
PWN::Plugins::Vault.encrypt(file: path, key: key, iv: iv)
|
|
73
|
+
ciphertext = File.binread(path)
|
|
74
|
+
PWN::Env[:driver_opts] = { pwn_env_path: path }
|
|
75
|
+
reader, writer = IO.pipe
|
|
76
|
+
pid = fork do
|
|
77
|
+
reader.close
|
|
78
|
+
allow(OpenSSL::Cipher).to receive(:new).and_wrap_original do |method, *args|
|
|
79
|
+
cipher = method.call(*args)
|
|
80
|
+
allow(cipher).to receive(:encrypt) do
|
|
81
|
+
writer.write('ready')
|
|
82
|
+
sleep 60
|
|
83
|
+
end
|
|
84
|
+
cipher
|
|
85
|
+
end
|
|
86
|
+
described_class.persist_ai_selection(engine: 'openai', model: 'offline')
|
|
87
|
+
exit! 1
|
|
88
|
+
end
|
|
89
|
+
writer.close
|
|
90
|
+
Timeout.timeout(5) { expect(reader.read(5)).to eq('ready') }
|
|
91
|
+
Process.kill('KILL', pid)
|
|
92
|
+
Process.wait(pid)
|
|
93
|
+
pid = nil
|
|
94
|
+
expect(File.binread(path)).to eq(ciphertext)
|
|
95
|
+
# Shared Vault.update encrypts before allocating a ciphertext-only stage.
|
|
96
|
+
stages = Dir.glob(File.join(@tmp, '{.pwn-ai-selection*,.pwn-vault-*}'))
|
|
97
|
+
expect(stages).to be_empty
|
|
98
|
+
ensure
|
|
99
|
+
Process.kill('KILL', pid) if pid
|
|
100
|
+
Process.wait(pid) if pid
|
|
101
|
+
reader&.close
|
|
102
|
+
writer&.close unless writer&.closed?
|
|
103
|
+
PWN::Env[:driver_opts] = previous_driver_opts
|
|
104
|
+
end
|
|
105
|
+
|
|
54
106
|
it 'rejects role saves without a decryptor or after switching the active engine' do
|
|
55
107
|
previous_driver_opts = PWN::Env[:driver_opts]
|
|
56
108
|
PWN::Env[:driver_opts] = { pwn_env_path: File.join(@tmp, 'missing.yaml'), pwn_dec_path: File.join(@tmp, 'missing.decryptor') }
|
|
@@ -783,15 +835,14 @@ describe PWN::Plugins::REPL do # rubocop:disable Metrics/BlockLength
|
|
|
783
835
|
yaml = File.join(dir, 'pwn.yaml')
|
|
784
836
|
dec = "#{yaml}.decryptor"
|
|
785
837
|
File.write(yaml, YAML.dump({ plugins: { meshtastic: { transport: 'mqtt', channel: { active: 'LongFast' } } } }))
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
allow(PWN::Plugins::Vault).to receive(:encrypt)
|
|
838
|
+
PWN::Plugins::Vault.create(file: yaml, decryptor_file: dec)
|
|
839
|
+
credentials = YAML.safe_load_file(dec, symbolize_names: true)
|
|
789
840
|
prev = PWN::Env[:driver_opts]
|
|
790
841
|
PWN::Env[:driver_opts] = { pwn_env_path: yaml, pwn_dec_path: dec }
|
|
791
842
|
described_class.persist_mesh_env(
|
|
792
843
|
mesh: { transport: 'serial', channel: { active: 'LongFast' }, serial: { port: '/dev/ttyACM0' } }
|
|
793
844
|
)
|
|
794
|
-
cfg =
|
|
845
|
+
cfg = PWN::Plugins::Vault.dump({ file: yaml }.merge(credentials))
|
|
795
846
|
expect(cfg.dig(:plugins, :meshtastic, :transport).to_s).to eq('serial')
|
|
796
847
|
expect(cfg.dig(:plugins, :meshtastic, :channel, :active).to_s).to eq('LongFast')
|
|
797
848
|
expect(cfg.dig(:plugins, :meshtastic, :serial, :port)).to eq('/dev/ttyACM0')
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe PWN::Plugins::TransparentBrowser, 'jev action loop' do
|
|
6
|
+
let(:browser) { double('browser', goto: nil) }
|
|
7
|
+
let(:browser_obj) { { type: :headless, browser: browser } }
|
|
8
|
+
|
|
9
|
+
before do
|
|
10
|
+
Thread.current[:pwn_jev_model] = nil
|
|
11
|
+
allow(PWN::Env).to receive(:dig).and_call_original
|
|
12
|
+
allow(PWN::Env).to receive(:dig).with(:ai, :openwebui).and_return({ base_uri: 'https://ow.example', key: 'token', model: 'llama' })
|
|
13
|
+
allow(PWN::Env).to receive(:dig).with(:ai, :active).and_return(:grok)
|
|
14
|
+
allow(PWN::Env).to receive(:dig).with(:ai, :grok, :model).and_return('grok-4')
|
|
15
|
+
allow(PWN::AI::OpenWebUI).to receive(:get_models).and_return([{ id: 'jev-27b' }])
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
after { Thread.current[:pwn_jev_model] = nil }
|
|
19
|
+
|
|
20
|
+
it 'stays inactive when Open WebUI has no Jev model' do
|
|
21
|
+
allow(PWN::AI::OpenWebUI).to receive(:get_models).and_return([{ id: 'llama' }])
|
|
22
|
+
expect(described_class.act(browser_obj: browser_obj, goal: 'find the article')).to include(available: false, status: 'unavailable')
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'asks Jev for the operation and the active engine only for typed text' do
|
|
26
|
+
scripts = []
|
|
27
|
+
allow(browser).to receive(:execute_script) do |script|
|
|
28
|
+
if script.include?('querySelectorAll')
|
|
29
|
+
{ 'url' => 'https://example.test', 'title' => 'Example', 'rows' => [{ 'index' => 1, 'role' => 'textbox', 'name' => 'Where to?', 'value' => '' }] }
|
|
30
|
+
else
|
|
31
|
+
scripts << script
|
|
32
|
+
{ 'status' => scripts.length == 1 ? 'acted' : 'done' }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
decisions = ['{"operation":"TYPE_TEXT","target":1}', '{"operation":"DONE","target":0}']
|
|
36
|
+
allow(PWN::AI::OpenWebUI).to receive(:chat) { { choices: [{ role: 'assistant', content: decisions.shift }] } }
|
|
37
|
+
allow(PWN::AI::Grok).to receive(:chat).and_return({ choices: [{ role: 'assistant', content: '{"text":"London"}' }] })
|
|
38
|
+
|
|
39
|
+
result = described_class.act(browser_obj: browser_obj, goal: 'Search for London', steps: 2)
|
|
40
|
+
|
|
41
|
+
expect(result).to include(available: true, status: 'done', jev_model: 'jev-27b', text_engine: :grok, text_model: 'grok-4')
|
|
42
|
+
expect(PWN::AI::Grok).to have_received(:chat)
|
|
43
|
+
expect(scripts.first).to include('London')
|
|
44
|
+
expect(scripts.first).not_to include('querySelectorAll')
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe PWN::Plugins::Vault, 'native YubiKey selectors' do
|
|
6
|
+
before do
|
|
7
|
+
@device = double('offline native YubiKey')
|
|
8
|
+
stub_const('PWN::Plugins::YubiKey', @device)
|
|
9
|
+
allow(@device).to receive(:challenge_response) { |opts| OpenSSL::HMAC.digest('SHA1', 'fixture-slot', opts[:challenge]) }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it 'forwards caller-owned native controls unchanged and persists only public slot and serial selectors' do
|
|
13
|
+
connection = double('native connection', exchange: ''.b)
|
|
14
|
+
selector = { slot: 1, serial: '001234', device_path: '/fixture/native-device', connection: connection, timeout: 1.5 }.freeze
|
|
15
|
+
expect(@device).to receive(:challenge_response).with(hash_including(selector)).twice
|
|
16
|
+
envelope = described_class.seal_credentials(credentials: { token: 'fixture' }, yubikey: selector)
|
|
17
|
+
expect(envelope.fetch('yubikey').keys).to contain_exactly('slot', 'serial', 'challenge')
|
|
18
|
+
expect(envelope.fetch('yubikey')).to include('slot' => 1, 'serial' => '001234')
|
|
19
|
+
expect(described_class.open_credentials(envelope: envelope, yubikey: selector)).to eq('token' => 'fixture')
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'accepts an exact caller-owned smartcard reader without serial and never persists it' do
|
|
23
|
+
selector = { slot: 2, reader: ' Fixture NFC reader ' }.freeze
|
|
24
|
+
expect(@device).to receive(:challenge_response).with(hash_including(selector)).twice
|
|
25
|
+
envelope = described_class.seal_credentials(credentials: {}, yubikey: selector)
|
|
26
|
+
expect(envelope.fetch('yubikey').keys).to contain_exactly('slot', 'challenge')
|
|
27
|
+
expect(described_class.open_credentials(envelope: envelope, yubikey: selector)).to eq({})
|
|
28
|
+
end
|
|
29
|
+
end
|