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
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require_relative '../support/harness/package_acceptance'
|
|
5
|
+
require_relative '../support/harness/cli_faults'
|
|
6
|
+
|
|
7
|
+
RSpec.describe 'harness process lifecycle' do
|
|
8
|
+
it 'preserves actual exit and stderr when startup fails before readiness' do
|
|
9
|
+
Dir.mktmpdir('pwn-harness-process-') do |root|
|
|
10
|
+
ready = File.join(root, 'ready')
|
|
11
|
+
command = [RbConfig.ruby, '-e', "warn 'unsupported architecture fixture'; exit 78"]
|
|
12
|
+
result = HarnessEvaluation.capture({ 'HOME' => root }, command, root, 1, ready_path: ready, startup_seconds: 2)
|
|
13
|
+
expect(result).to include(exit_status: 78, stderr: "unsupported architecture fixture\n", resource_cap: false, ready_observed: false, timeout_phase: nil)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'distinguishes a bounded boot timeout from an execution timeout' do
|
|
18
|
+
Dir.mktmpdir('pwn-harness-process-') do |root|
|
|
19
|
+
result = HarnessEvaluation.capture({ 'HOME' => root }, [RbConfig.ruby, '-e', 'sleep 60'], root, 1,
|
|
20
|
+
ready_path: File.join(root, 'ready'), startup_seconds: 0.05)
|
|
21
|
+
expect(result).to include(resource_cap: true, ready_observed: false, timeout_phase: 'startup', exit_status: nil, signal: Signal.list.fetch('KILL'))
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'reports the real child failure when its fingerprint is missing, without inventing one' do
|
|
26
|
+
Dir.mktmpdir('pwn-harness-process-') do |root|
|
|
27
|
+
process = HarnessEvaluation.capture({ 'HOME' => root }, [RbConfig.ruby, '-e', "warn 'namespace unavailable'; exit 78"], root, 2)
|
|
28
|
+
File.write(File.join(root, 'initial-process.json'), JSON.generate(process))
|
|
29
|
+
row = { id: 'checkpoint-restart', artifact_root: root }
|
|
30
|
+
expect { HarnessPackageAcceptance.validate_fingerprints!([row], '/isolated/gems/pwn') }
|
|
31
|
+
.to raise_error(RuntimeError) { |error| expect(error.message).to include('checkpoint-restart', 'fingerprint', '"exit_status":78', 'namespace unavailable') }
|
|
32
|
+
expect(File.exist?(File.join(root, 'fingerprint.json'))).to be(false)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'records observed loaded components before an abrupt fixture exit bypasses at_exit' do
|
|
37
|
+
Dir.mktmpdir('pwn-harness-process-') do |root|
|
|
38
|
+
fixture = File.join(root, 'provider.json')
|
|
39
|
+
fingerprint = File.join(root, 'fingerprint.json')
|
|
40
|
+
File.write(fixture, JSON.generate(responses: []))
|
|
41
|
+
env = { 'HOME' => root, 'GEM_HOME' => Gem.dir, 'GEM_PATH' => Gem.path.join(File::PATH_SEPARATOR),
|
|
42
|
+
'PWN_HARNESS_FIXTURE' => fixture, 'PWN_HARNESS_EVENTS' => File.join(root, 'events.jsonl'), 'PWN_HARNESS_FINGERPRINT' => fingerprint }
|
|
43
|
+
command = [RbConfig.ruby, '-I', File.join(HarnessEvaluation::ROOT, 'lib'), '-r',
|
|
44
|
+
File.join(HarnessEvaluation::ROOT, 'spec/support/harness/fake_provider.rb'), '-e', 'exit!(73)']
|
|
45
|
+
result = HarnessEvaluation.agent_capture(env, command, root, 5)
|
|
46
|
+
expect(result).to include(exit_status: 73, ready_observed: true)
|
|
47
|
+
expect(File.file?(fingerprint)).to be(true), result.inspect
|
|
48
|
+
expect(JSON.parse(File.read(fingerprint)).fetch('components')).not_to be_empty
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'loads checkpoint controls across the real namespace exec boundary' do
|
|
53
|
+
Dir.mktmpdir('pwn-harness-process-') do |root|
|
|
54
|
+
fixture = File.join(root, 'provider.json')
|
|
55
|
+
runtime = File.join(root, 'runtime')
|
|
56
|
+
File.write(fixture, JSON.generate(responses: [{ fault: 'crash' }]))
|
|
57
|
+
env = { 'HOME' => root, 'PATH' => HarnessEvaluation.isolated_path, 'GEM_HOME' => Gem.dir, 'GEM_PATH' => Gem.path.join(File::PATH_SEPARATOR),
|
|
58
|
+
'PWN_HARNESS_FIXTURE' => fixture, 'PWN_HARNESS_EVENTS' => File.join(root, 'events.jsonl'),
|
|
59
|
+
'PWN_HARNESS_FINGERPRINT' => File.join(root, 'fingerprint.json'), 'PWN_HARNESS_RUNTIME' => runtime }
|
|
60
|
+
command = [RbConfig.ruby, '-I', File.join(HarnessEvaluation::ROOT, 'lib'), '-r',
|
|
61
|
+
File.join(HarnessEvaluation::ROOT, 'spec/support/harness/fault_preload.rb'), '-e',
|
|
62
|
+
'PWN::AI::Agent::MissionNamespace.force_exec = true; load ARGV.shift', File.join(HarnessEvaluation::ROOT, 'bin/pwn-ai'), '--ai', 'Offline checkpoint fixture']
|
|
63
|
+
result = HarnessEvaluation.agent_capture(env, command, root, 10)
|
|
64
|
+
expect(result).to include(exit_status: 73)
|
|
65
|
+
expect(File.file?(File.join(runtime, 'identity.json'))).to be(true), result.inspect
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it 'does not resume a checkpoint that never started after a real child failure' do
|
|
70
|
+
Dir.mktmpdir('pwn-harness-process-') do |root|
|
|
71
|
+
failure = { exit_status: 78, resource_cap: false, ready_observed: false, stdout: '', stderr: 'namespace unavailable' }
|
|
72
|
+
allow(HarnessCLIFaults).to receive(:invoke).and_return(failure)
|
|
73
|
+
report = HarnessCLIFaults.run(output: root)
|
|
74
|
+
expect(HarnessCLIFaults).to have_received(:invoke).exactly(5).times
|
|
75
|
+
expect(report.fetch(:results)).to all(include(outcome: 'FAIL', exit_status: 78))
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it 'redacts child diagnostics before reporting a missing fingerprint' do
|
|
80
|
+
Dir.mktmpdir('pwn-harness-process-') do |root|
|
|
81
|
+
row = { id: 'failed-boot', artifact_root: root, exit_status: 78, stderr: 'password=offline-secret' }
|
|
82
|
+
expect { HarnessPackageAcceptance.validate_fingerprints!([row], '/isolated/gems/pwn') }.to raise_error(RuntimeError) do |error|
|
|
83
|
+
expect(error.message).to include('REDACTED', '78')
|
|
84
|
+
expect(error.message).not_to include('offline-secret')
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it 'requires fresh readiness on restart rather than accepting the previous process marker' do
|
|
90
|
+
Dir.mktmpdir('pwn-harness-process-') do |root|
|
|
91
|
+
File.write(File.join(root, 'harness-ready'), 'previous invocation')
|
|
92
|
+
command = [RbConfig.ruby, '-e', "sleep 0.2; puts 'booted'; STDOUT.flush; File.write(ENV.fetch('PWN_HARNESS_READY'), 'ready'); sleep 60"]
|
|
93
|
+
result = HarnessEvaluation.agent_capture({ 'HOME' => root }, command, root, 0.05)
|
|
94
|
+
expect(result).to include(resource_cap: true, ready_observed: true, stdout: "booted\n", timeout_phase: 'execution')
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'open3'
|
|
5
|
+
require 'tmpdir'
|
|
6
|
+
require 'fileutils'
|
|
7
|
+
require 'json'
|
|
8
|
+
|
|
9
|
+
RSpec.describe 'mission cancellation through real subprocesses' do
|
|
10
|
+
def mission_fixture(name, *args)
|
|
11
|
+
Dir.mktmpdir('mission-cancel-') do |root|
|
|
12
|
+
home = File.join(root, 'home')
|
|
13
|
+
work = File.join(root, 'work')
|
|
14
|
+
FileUtils.mkdir_p([home, work])
|
|
15
|
+
fixture = File.expand_path("../support/harness/#{name}.rb", __dir__)
|
|
16
|
+
stdout, stderr, status = Open3.capture3({ 'HOME' => home }, RbConfig.ruby, '-I', File.expand_path('../../lib', __dir__), fixture, work, *args, chdir: work)
|
|
17
|
+
expect(status.success?).to be(true), stderr
|
|
18
|
+
JSON.parse(stdout)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'cancels the legacy interactive entrypoint through a native terminal and accepts its next mission' do
|
|
23
|
+
require 'pty'
|
|
24
|
+
require 'timeout'
|
|
25
|
+
Dir.mktmpdir('mission-legacy-') do |root|
|
|
26
|
+
FileUtils.mkdir_p(File.join(root, 'home'))
|
|
27
|
+
fixture = File.expand_path('../support/harness/mission_legacy.rb', __dir__)
|
|
28
|
+
PTY.spawn({ 'HOME' => File.join(root, 'home') }, RbConfig.ruby, '-I', File.expand_path('../../lib', __dir__), fixture, root, chdir: root) do |reader, writer, pid|
|
|
29
|
+
Timeout.timeout(15) { sleep 0.01 until File.exist?(File.join(root, 'ready')) }
|
|
30
|
+
writer.write("\x03")
|
|
31
|
+
output = +''
|
|
32
|
+
Timeout.timeout(15) do
|
|
33
|
+
loop { output << reader.readpartial(4096) }
|
|
34
|
+
rescue EOFError, Errno::EIO
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
_child, status = Process.wait2(pid)
|
|
38
|
+
expect(status.success?).to be(true), output
|
|
39
|
+
row = output.lines.find { |line| line.start_with?('{') }
|
|
40
|
+
expect(JSON.parse(row)).to include('ready' => true, 'late' => false, 'second' => true, 'tty' => true)
|
|
41
|
+
ensure
|
|
42
|
+
begin
|
|
43
|
+
Process.kill('KILL', pid)
|
|
44
|
+
Process.wait(pid)
|
|
45
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'reaps provider execution before accepting the next console mission' do
|
|
53
|
+
expect(mission_fixture('mission_console')).to include('ready' => true, 'late' => false, 'second' => true, 'fenced' => true)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it 'hard cancels local command descendants before the next mission' do
|
|
57
|
+
expect(mission_fixture('mission_console', 'command')).to include('ready' => true, 'late' => false, 'second' => true, 'fenced' => true)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'keeps detached tool jobs alive until their enclosing mission exits' do
|
|
61
|
+
expect(mission_fixture('mission_job_lifetime')).to include('survived_tool' => true)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it 'hard cancels independent swarm provider and tool descendants' do
|
|
65
|
+
expect(mission_fixture('mission_swarm')).to include('ready' => true, 'late' => false)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it 'fails closed and reaps startup when namespace creation is denied' do
|
|
69
|
+
expect(mission_fixture('mission_unsupported')).to include('blocked' => true, 'actionable' => true, 'reaped' => true)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it 'owns double-forked jobs on normal exit without killing unrelated processes' do
|
|
73
|
+
expect(mission_fixture('mission_normal_exit')).to include('reaped' => true, 'late' => false, 'unrelated' => true)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it 'stops and reaps a setsid descendant before cancellation returns' do
|
|
77
|
+
expect(mission_fixture('mission_dispatch')).to include('cancelled' => true, 'descendant_started' => true, 'reaped' => true, 'post_cancel_write' => false)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'pty'
|
|
5
|
+
require 'io/console'
|
|
6
|
+
require 'timeout'
|
|
7
|
+
|
|
8
|
+
RSpec.describe 'native curses mission cancellation' do
|
|
9
|
+
%w[ordinary command].each do |mode|
|
|
10
|
+
it "handles terminal CTRL+C during #{mode} execution and accepts the next mission without late writes" do
|
|
11
|
+
Dir.mktmpdir('mission-curses-') do |root|
|
|
12
|
+
home = File.join(root, 'home')
|
|
13
|
+
work = File.join(root, 'work')
|
|
14
|
+
FileUtils.mkdir_p([home, work])
|
|
15
|
+
fixture = File.expand_path('../support/harness/mission_curses_pty.rb', __dir__)
|
|
16
|
+
PTY.spawn({ 'HOME' => home, 'TERM' => 'xterm-256color' }, RbConfig.ruby, '-I', File.expand_path('../../lib', __dir__), fixture, work, mode, chdir: work) do |reader, writer, pid|
|
|
17
|
+
writer.winsize = [32, 120]
|
|
18
|
+
output = +''
|
|
19
|
+
pump = Thread.new do
|
|
20
|
+
loop { output << reader.readpartial(4096) }
|
|
21
|
+
rescue EOFError, Errno::EIO
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
%w[idle ready cancelled second].zip([mode == 'command' ? "/mcp fixture\r" : "first\r", "\x03", "second\r", "back\r"]).each do |marker, keys|
|
|
25
|
+
Timeout.timeout(20) { sleep 0.01 until File.exist?(File.join(work, marker)) }
|
|
26
|
+
writer.write(keys)
|
|
27
|
+
end
|
|
28
|
+
Timeout.timeout(20) { pump.join }
|
|
29
|
+
_child, status = Process.wait2(pid)
|
|
30
|
+
expect(status.success?).to be(true), output
|
|
31
|
+
expect(JSON.parse(output[/\{"ready":.*\}/])).to include('ready' => true, 'late' => false, 'second' => true, 'tty' => true)
|
|
32
|
+
ensure
|
|
33
|
+
begin
|
|
34
|
+
Process.kill('KILL', pid)
|
|
35
|
+
Process.wait(pid)
|
|
36
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
pump&.join(1)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe 'README security overview', :aggregate_failures do
|
|
6
|
+
let(:root) { File.expand_path('../..', __dir__) }
|
|
7
|
+
let(:readme) { File.read(File.join(root, 'README.md')) }
|
|
8
|
+
let(:security) do
|
|
9
|
+
readme[/^##### \*\*Vault and Sandboxing\*\* #####\n(.*?)(?=^#### )/m, 1].to_s.gsub(/\s+/, ' ')
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it 'links a security subsection under Why PWN without replacing How PWN Works' do
|
|
13
|
+
expect(readme).to include('[Vault and Sandboxing](#vault-and-sandboxing)')
|
|
14
|
+
expect(readme).to match(/^#### \*\*Why PWN\*\* ####.*^##### \*\*Vault and Sandboxing\*\* #####.*^#### \*\*How PWN Works\*\* ####/m)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'explains credential handling at the tool boundary and engagement loot scope' do
|
|
18
|
+
expect(security).to include('security research', 'bug bounty hunting', 'penetration testing',
|
|
19
|
+
'at rest', '{{vault:label}}', 'Dispatch', 'tool boundary',
|
|
20
|
+
'prompts', 'logs', 'engagement', 'provenance', 'across engagements')
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'documents the mandatory local boundary independently of optional sandbox profiles' do
|
|
24
|
+
expect(security).to include('untrusted PoCs', 'prompt injection', '~/.pwn/workspaces/runtime',
|
|
25
|
+
'original operator request', 'parent', 'source', 'controller state',
|
|
26
|
+
'read-only even if named', 'fail closed', 'Landlock', 'seccomp',
|
|
27
|
+
'namespace', '`strict`', '`permissive`', '`off`')
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'keeps encryption, provider privacy and operator responsibilities distinct' do
|
|
31
|
+
expect(security).to include('active process', 'LLM-provider privacy', 'whole-filesystem',
|
|
32
|
+
'evidence/artifact encryption', 'redaction can miss',
|
|
33
|
+
'not authorization', 'network isolation', 'perfect container',
|
|
34
|
+
'guarantee against bugs', 'target authorization', 'least-privilege',
|
|
35
|
+
'retention')
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'links implementation and boundary documentation with existing local targets' do
|
|
39
|
+
expect(security).to include('(lib/pwn/plugins/vault.rb)', '(lib/pwn/ai/agent/dispatch.rb)',
|
|
40
|
+
'(documentation/Runtime-Source-Protection.md)',
|
|
41
|
+
'(documentation/Mission-Cancellation.md)',
|
|
42
|
+
'(documentation/Configuration.md)', '(documentation/YubiKey-Vaults.md)')
|
|
43
|
+
refs = readme.scan(/\]\((?!https?:|#|mailto:)([^)\s]+)\)/).flatten.uniq
|
|
44
|
+
broken = refs.reject { |ref| File.exist?(File.expand_path(ref.split('#', 2).first, root)) }
|
|
45
|
+
expect(broken).to be_empty, "broken README relative links: #{broken.join(', ')}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe 'pwn_web_cache_deception YubiKey options' do
|
|
6
|
+
let(:script) { File.expand_path('../../bin/pwn_web_cache_deception', __dir__) }
|
|
7
|
+
let(:browser) { double('offline browser', goto: nil, html: '<html>offline</html>') }
|
|
8
|
+
let(:browser_obj) { { browser: browser } }
|
|
9
|
+
let(:provider) { double('offline WWW provider', open: browser_obj, login: browser_obj, logout: browser_obj, close: nil) }
|
|
10
|
+
let(:driver_opts) { {} }
|
|
11
|
+
let(:account) { 'Example:tester@example.invalid' }
|
|
12
|
+
let(:serial) { '00123456' }
|
|
13
|
+
let(:args) do
|
|
14
|
+
['--target-url=https://example.invalid/test', "--output-results=#{File.join(@tmpdir, 'results.txt')}",
|
|
15
|
+
'--pwn-www-mod=GitHub', '--username=tester@example.invalid', '--password=fake-password',
|
|
16
|
+
"--yubikey-account=#{account}", '--yubikey-confirm', "--yubikey-serial=#{serial}", '--no-mfa']
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
around do |example|
|
|
20
|
+
Dir.mktmpdir('pwn-wcd-yubikey-') do |dir|
|
|
21
|
+
@tmpdir = dir
|
|
22
|
+
example.run
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
before do
|
|
27
|
+
stub_const('ARGV', args.dup)
|
|
28
|
+
allow(PWN::Env).to receive(:[]).and_call_original
|
|
29
|
+
allow(PWN::Env).to receive(:[]).with(:driver_opts).and_return(driver_opts)
|
|
30
|
+
allow(PWN::Config).to receive(:refresh_env)
|
|
31
|
+
stub_const('PWN::WWW::GitHub', provider)
|
|
32
|
+
stub_const('PWN::Plugins::YubiKey', double('offline device boundary'))
|
|
33
|
+
stub_const('PWN::Plugins::TransparentBrowser', double('offline browser boundary', close: nil))
|
|
34
|
+
allow(PWN::Plugins::AuthenticationHelper).to receive(:mask_password).and_raise('unexpected password prompt')
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'forwards exact account and serial options to GitHub login even with --no-mfa' do
|
|
38
|
+
expected = { browser_obj: browser_obj, username: 'tester@example.invalid', password: 'fake-password', mfa: false, yubikey: { account: account, confirm: true, serial: serial } }
|
|
39
|
+
expect(provider).to receive(:login).with(expected).and_return(browser_obj)
|
|
40
|
+
expect(PWN::Plugins::YubiKey).not_to receive(:oath_code)
|
|
41
|
+
load script, true
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
%w[Synack Twitter BugCrowd CoinbasePro AppCobaltIO].each do |name|
|
|
45
|
+
it "forwards YubiKey options only at #{name} login, not browser open" do
|
|
46
|
+
ARGV.map! { |arg| arg == '--pwn-www-mod=GitHub' ? "--pwn-www-mod=#{name}" : arg }
|
|
47
|
+
stub_const("PWN::WWW::#{name}", provider)
|
|
48
|
+
expect(provider).to receive(:open).with(browser_type: :chrome).and_return(browser_obj)
|
|
49
|
+
expect(provider).to receive(:login).with(hash_including(yubikey: { account: account, confirm: true, serial: serial }, mfa: false)).and_return(browser_obj)
|
|
50
|
+
load script, true
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
%w[Google Paypal].each do |name|
|
|
55
|
+
it "rejects YubiKey for #{name} before password prompts or opening a browser" do
|
|
56
|
+
ARGV.map! { |arg| arg == '--pwn-www-mod=GitHub' ? "--pwn-www-mod=#{name}" : arg }
|
|
57
|
+
ARGV.delete('--password=fake-password')
|
|
58
|
+
stub_const("PWN::WWW::#{name}", provider)
|
|
59
|
+
expect(provider).not_to receive(:open)
|
|
60
|
+
expect(provider).not_to receive(:login)
|
|
61
|
+
expect { load script, true }.to raise_error(ArgumentError, /YubiKey OATH.*supported/)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it 'requires a selected compatible provider before prompting or opening a browser' do
|
|
66
|
+
ARGV.delete('--pwn-www-mod=GitHub')
|
|
67
|
+
ARGV.delete('--password=fake-password')
|
|
68
|
+
expect { load script, true }.to raise_error(ArgumentError, /YubiKey OATH.*supported/)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it 'rejects a serial without an exact account instead of silently disabling YubiKey' do
|
|
72
|
+
ARGV.delete("--yubikey-account=#{account}")
|
|
73
|
+
ARGV.delete('--password=fake-password')
|
|
74
|
+
expect(provider).not_to receive(:open)
|
|
75
|
+
expect { load script, true }.to raise_error(ArgumentError, /serial.*account/)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it 'requires --yubikey-confirm before password prompts or browser open' do
|
|
79
|
+
ARGV.delete('--yubikey-confirm')
|
|
80
|
+
ARGV.delete('--password=fake-password')
|
|
81
|
+
expect(provider).not_to receive(:open)
|
|
82
|
+
expect(provider).not_to receive(:login)
|
|
83
|
+
expect { load script, true }.to raise_error(ArgumentError, /confirm: true.*HOTP.*authenticat/i)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
it 'rejects confirmation without an account before password prompts or browser open' do
|
|
87
|
+
ARGV.reject! { |arg| arg.start_with?('--yubikey-account=', '--yubikey-serial=') || arg == '--password=fake-password' }
|
|
88
|
+
expect(provider).not_to receive(:open)
|
|
89
|
+
expect(provider).not_to receive(:login)
|
|
90
|
+
expect { load script, true }.to raise_error(ArgumentError, /--yubikey-confirm.*--yubikey-account/)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it 'does not accept a string value for the boolean --yubikey-confirm flag' do
|
|
94
|
+
ARGV.map! { |arg| arg == '--yubikey-confirm' ? '--yubikey-confirm=true' : arg }
|
|
95
|
+
expect(provider).not_to receive(:open)
|
|
96
|
+
expect { load script, true }.to raise_error(OptionParser::NeedlessArgument)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
['', '123x', ' 123', '-1'].each do |invalid|
|
|
100
|
+
it "rejects malformed serial #{invalid.inspect} before prompting or opening a browser" do
|
|
101
|
+
ARGV.map! { |arg| arg == "--yubikey-serial=#{serial}" ? "--yubikey-serial=#{invalid}" : arg }
|
|
102
|
+
ARGV.delete('--password=fake-password')
|
|
103
|
+
expect(provider).not_to receive(:open)
|
|
104
|
+
expect { load script, true }.to raise_error(ArgumentError, /serial.*decimal/)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'validates an empty account before password prompts or browser open' do
|
|
109
|
+
ARGV.map! { |arg| arg == "--yubikey-account=#{account}" ? '--yubikey-account=' : arg }
|
|
110
|
+
ARGV.delete('--yubikey-confirm')
|
|
111
|
+
ARGV.delete('--password=fake-password')
|
|
112
|
+
expect(provider).not_to receive(:open)
|
|
113
|
+
expect { load script, true }.to raise_error(ArgumentError, /account/)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it 'preserves an explicit MFA token from driver options rather than coercing it to true' do
|
|
117
|
+
ARGV.reject! { |arg| arg.start_with?('--yubikey-') || arg == '--no-mfa' }
|
|
118
|
+
driver_opts[:mfa] = '654321'
|
|
119
|
+
expect(provider).to receive(:login).with(hash_including(mfa: '654321')).and_return(browser_obj)
|
|
120
|
+
expect { load script, true }.not_to output(/fake-password|654321/).to_stdout
|
|
121
|
+
expect(File.read(File.join(@tmpdir, 'results.txt'))).not_to match(/fake-password|654321/)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it 'keeps the generic driver options unchanged when no YubiKey is requested' do
|
|
125
|
+
ARGV.reject! { |arg| arg.start_with?('--yubikey-') }
|
|
126
|
+
ARGV.map! { |arg| arg == '--pwn-www-mod=GitHub' ? '--pwn-www-mod=Google' : arg }
|
|
127
|
+
stub_const('PWN::WWW::Google', provider)
|
|
128
|
+
expect(provider).to receive(:login) do |options|
|
|
129
|
+
expect(options).not_to have_key(:yubikey)
|
|
130
|
+
expect(options[:mfa]).to be(false)
|
|
131
|
+
browser_obj
|
|
132
|
+
end
|
|
133
|
+
load script, true
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
it 'preserves the exact OATH name when the optional serial is omitted' do
|
|
137
|
+
exact = " #{account} "
|
|
138
|
+
ARGV.reject! { |arg| arg.start_with?('--yubikey-serial=') }
|
|
139
|
+
ARGV.map! { |arg| arg == "--yubikey-account=#{account}" ? "--yubikey-account=#{exact}" : arg }
|
|
140
|
+
expect(provider).to receive(:login).with(hash_including(yubikey: { account: exact, confirm: true })).and_return(browser_obj)
|
|
141
|
+
expect { load script, true }.not_to output(/fake-password|012345/).to_stdout
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
it 'documents account, consent risks, and serial flags with the supported-provider boundary' do
|
|
145
|
+
ARGV.replace(['--help'])
|
|
146
|
+
expect do
|
|
147
|
+
expect { load script, true }.to raise_error(SystemExit) { |error| expect(error.status).to eq(0) }
|
|
148
|
+
end.to output(/--yubikey-account.*GitHub.*Synack.*Twitter.*BugCrowd.*CoinbasePro.*AppCobaltIO.*Paypal.*--yubikey-confirm.*HOTP.*delete.*corrupt.*--yubikey-serial/m).to_stdout
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
it 'rejects an explicit token conflicting with YubiKey before password prompts' do
|
|
152
|
+
ARGV.delete('--no-mfa')
|
|
153
|
+
ARGV.delete('--yubikey-confirm')
|
|
154
|
+
ARGV.delete('--password=fake-password')
|
|
155
|
+
driver_opts[:mfa] = '654321'
|
|
156
|
+
expect(provider).not_to receive(:open)
|
|
157
|
+
expect { load script, true }.to raise_error(ArgumentError, /yubikey.*mfa/i)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -6,18 +6,21 @@ require 'json'
|
|
|
6
6
|
describe PWN::AI::Agent::Dispatch do
|
|
7
7
|
it 'does not replay a timed-out real shell mutation after restoring its request checkpoint' do
|
|
8
8
|
Dir.mktmpdir do |root|
|
|
9
|
-
|
|
9
|
+
work = File.join(root, 'work')
|
|
10
|
+
FileUtils.mkdir_p(work)
|
|
11
|
+
allow(PWN::Plugins::AISandbox).to receive(:workspace).and_return(work)
|
|
12
|
+
options = { request: 'Write counter', session_id: 'timeout', request_id: 'fixture', artifact_root: File.join(root, 'control'), trace: false }
|
|
10
13
|
runtime = PWN::AI::Agent::RequestRuntime.new(options)
|
|
11
14
|
Thread.current[:pwn_request_runtime] = runtime
|
|
12
15
|
PWN::AI::Agent::Registry.discover
|
|
13
|
-
call = { id: 'mutation', function: { name: 'shell', arguments: { command: "printf effect >> #{
|
|
16
|
+
call = { id: 'mutation', function: { name: 'shell', arguments: { command: "printf effect >> #{work}/counter; sleep 3", timeout: 1 } } }
|
|
14
17
|
first = JSON.parse(described_class.call(tool_call: call, scope_policy: { enabled: false }))
|
|
15
18
|
expect(first.dig('result', 'error')).to include('timeout')
|
|
16
19
|
Thread.current[:pwn_request_runtime] = PWN::AI::Agent::RequestRuntime.new(options.merge(resume: true))
|
|
17
20
|
second = JSON.parse(described_class.call(tool_call: call, scope_policy: { enabled: false }))
|
|
18
21
|
expect(second['success']).to be false
|
|
19
22
|
expect(second.dig('result', 'error')).to eq('uncertain_side_effect')
|
|
20
|
-
expect(File.read(File.join(
|
|
23
|
+
expect(File.read(File.join(work, 'counter'))).to eq('effect')
|
|
21
24
|
expect(call.dig(:function, :arguments, :timeout)).to eq(1)
|
|
22
25
|
end
|
|
23
26
|
ensure
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
RSpec.describe PWN::AI::Agent::Dispatch, 'vault access safety' do
|
|
7
|
+
before do
|
|
8
|
+
@calls = []
|
|
9
|
+
@result = { status: 'fixture-complete' }
|
|
10
|
+
handler = lambda do |args|
|
|
11
|
+
@calls << args
|
|
12
|
+
@result
|
|
13
|
+
end
|
|
14
|
+
entry = PWN::AI::Agent::Registry::Entry.new(name: 'vault_fixture', schema: { parameters: { type: 'object' } }, handler: handler)
|
|
15
|
+
allow(PWN::AI::Agent::Registry).to receive(:lookup).with(name: 'vault_fixture').and_return(entry)
|
|
16
|
+
allow(PWN::AI::Agent::Manifest).to receive(:load).and_return({})
|
|
17
|
+
allow(PWN::AI::Agent::Manifest).to receive(:check).and_return(nil)
|
|
18
|
+
allow(PWN::AI::Agent::ToolGuard).to receive_messages(scope_check!: nil, policy_decision: nil, canary_leak?: false, refuse_copied_persist?: false)
|
|
19
|
+
allow(PWN::AI::Agent::Engagement).to receive(:deny_if_out_of_scope).and_return(nil)
|
|
20
|
+
allow(PWN::AI::Agent::Mission).to receive(:active).and_return(nil)
|
|
21
|
+
allow(PWN::AI::Agent::Confirmation).to receive(:gate).and_return(nil)
|
|
22
|
+
allow(PWN::Plugins::Vault).to receive(:expand) { |opts| opts[:text] }
|
|
23
|
+
allow(PWN::Plugins::Vault).to receive(:redact) { |opts| opts[:text] }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'expands nested vault references once per leaf before invoking the handler' do
|
|
27
|
+
allow(PWN::Plugins::Vault).to receive(:expand).with(text: '{{vault:fixture}}').and_return('fixture-unlocked')
|
|
28
|
+
arguments = { nested: [{ secret: '{{vault:fixture}}' }], slot: 2 }
|
|
29
|
+
response = JSON.parse(described_class.call(tool_call: { function: { name: 'vault_fixture', arguments: arguments } }))
|
|
30
|
+
expect(response['success']).to be(true)
|
|
31
|
+
expect(@calls).to eq([{ nested: [{ secret: 'fixture-unlocked' }], slot: 2 }])
|
|
32
|
+
expect(PWN::Plugins::Vault).to have_received(:expand).with(text: '{{vault:fixture}}').once
|
|
33
|
+
expect(arguments[:nested].first[:secret]).to eq('{{vault:fixture}}')
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'blocks a nested vault access failure without exposing its exception text' do
|
|
37
|
+
allow(PWN::Plugins::Vault).to receive(:expand).with(text: '{{vault:fixture}}').and_raise(IOError, 'fixture-private-detail')
|
|
38
|
+
response = JSON.parse(described_class.call(tool_call: { function: { name: 'vault_fixture', arguments: { nested: ['{{vault:fixture}}'] } } }))
|
|
39
|
+
expect(response).to include('success' => false, 'code' => 'VAULT_EXPANSION_FAILED')
|
|
40
|
+
expect(@calls).to be_empty
|
|
41
|
+
expect(response.to_s).not_to include('fixture-private-detail')
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'withholds results when vault output redaction fails after handler execution' do
|
|
45
|
+
allow(PWN::Plugins::Vault).to receive(:redact).and_raise(IOError, 'fixture-private-detail')
|
|
46
|
+
response = JSON.parse(described_class.call(tool_call: { function: { name: 'vault_fixture', arguments: {} } }))
|
|
47
|
+
expect(@calls.length).to eq(1)
|
|
48
|
+
expect(response).to include('success' => false, 'code' => 'VAULT_REDACTION_FAILED', 'handler_executed' => true)
|
|
49
|
+
expect(response.to_s).not_to include('fixture-private-detail', 'fixture-complete')
|
|
50
|
+
expect(response).not_to have_key('result')
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it 'redacts secrets in array results such as loot offers before returning them' do
|
|
54
|
+
@result = [{ secret: 'fixture-private-value' }]
|
|
55
|
+
allow(PWN::Plugins::Vault).to receive(:redact) { |opts| opts[:text].gsub('fixture-private-value', '{{vault:fixture}}') }
|
|
56
|
+
response = JSON.parse(described_class.call(tool_call: { function: { name: 'vault_fixture', arguments: {} } }))
|
|
57
|
+
expect(response).to include('success' => true, 'result' => [{ 'secret' => '{{vault:fixture}}' }])
|
|
58
|
+
expect(response.to_s).not_to include('fixture-private-value')
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it 'blocks a real hardware-envelope expansion when the offline device fixture fails' do
|
|
62
|
+
Dir.mktmpdir('pwn-dispatch-yubikey-') do |root|
|
|
63
|
+
allow(Dir).to receive(:home).and_return(root)
|
|
64
|
+
allow(PWN::AI::Agent::Mission).to receive(:active_id).and_return(nil)
|
|
65
|
+
device = double('offline YubiKey fixture')
|
|
66
|
+
stub_const('PWN::Plugins::YubiKey', device)
|
|
67
|
+
allow(device).to receive(:challenge_response) { |opts| OpenSSL::HMAC.digest('SHA1', 'synthetic-slot-secret', opts.fetch(:challenge)) }
|
|
68
|
+
PWN::Plugins::Vault.store(label: 'fixture', secret: 'fixture-private-value', yubikey: { slot: 2 })
|
|
69
|
+
allow(PWN::Plugins::Vault).to receive(:expand).and_call_original
|
|
70
|
+
allow(device).to receive(:challenge_response).and_raise(IOError, 'fixture-private-detail')
|
|
71
|
+
response = JSON.parse(described_class.call(tool_call: { function: { name: 'vault_fixture', arguments: { password: '{{vault:fixture}}' } } }))
|
|
72
|
+
expect(response).to include('success' => false, 'code' => 'VAULT_EXPANSION_FAILED')
|
|
73
|
+
expect(@calls).to be_empty
|
|
74
|
+
expect(response.to_s).not_to include('fixture-private-detail', 'fixture-private-value')
|
|
75
|
+
expect(File).not_to exist(File.join(root, '.pwn-vault.key'))
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it 'never invokes a real handler after expansion of a vault argument fails' do
|
|
80
|
+
allow(PWN::Plugins::Vault).to receive(:expand).with(text: '{{vault:fixture}}').and_raise(IOError, 'device absent; fixture-private-detail')
|
|
81
|
+
response = JSON.parse(described_class.call(tool_call: { function: { name: 'vault_fixture', arguments: { password: '{{vault:fixture}}' } } }))
|
|
82
|
+
expect(@calls).to be_empty
|
|
83
|
+
expect(response).to include('success' => false, 'code' => 'VAULT_EXPANSION_FAILED')
|
|
84
|
+
expect(response.to_s).not_to include('fixture-private-detail')
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -34,6 +34,7 @@ describe 'indirect prompt-injection guards' do
|
|
|
34
34
|
it 'does not re-infer the acceptance contract on a nested loop' do
|
|
35
35
|
Thread.current[:pwn_loop_active] = true
|
|
36
36
|
Thread.current[:pwn_loop_nested] = true
|
|
37
|
+
PWN::AI::Agent::TurnFinalizer.commit_artifacts!(request: 'Write /tmp/frozen.pdf')
|
|
37
38
|
Thread.current[:pwn_loop_deliverables] = {
|
|
38
39
|
paths: ['/tmp/frozen.pdf'], min_seconds: 0, skills: [], proofs: [], hosts: []
|
|
39
40
|
}
|
|
@@ -44,6 +45,7 @@ describe 'indirect prompt-injection guards' do
|
|
|
44
45
|
ensure
|
|
45
46
|
Thread.current[:pwn_loop_active] = nil
|
|
46
47
|
Thread.current[:pwn_loop_nested] = nil
|
|
48
|
+
Thread.current[:pwn_artifact_contract] = nil
|
|
47
49
|
Thread.current[:pwn_loop_deliverables] = nil
|
|
48
50
|
end
|
|
49
51
|
|
|
@@ -89,7 +89,7 @@ describe PWN::AI::Agent::Learning do
|
|
|
89
89
|
expect(described_class.evidence_complete?(row: row)).to be(true)
|
|
90
90
|
expect(File.binread(@source)).to eq(original_source)
|
|
91
91
|
migration = Digest::SHA256.hexdigest(@original)
|
|
92
|
-
backup = File.
|
|
92
|
+
backup = JSON.parse(File.read(result[:ledger])).fetch('backup')
|
|
93
93
|
expect(File.binread(backup)).to eq(@original.b)
|
|
94
94
|
expect(described_class.rollback_evidence(migration: migration)[:restored]).to be(true)
|
|
95
95
|
expect(File.binread(@learning)).to eq(@original.b)
|
|
@@ -778,14 +778,14 @@ describe 'PWN::AI::Agent::Learning conflicted outcomes' do
|
|
|
778
778
|
end
|
|
779
779
|
end
|
|
780
780
|
|
|
781
|
-
it '
|
|
781
|
+
it 'leaves legacy siblings untouched and reports the global seven-folder policy' do
|
|
782
782
|
Dir.mktmpdir do |dir|
|
|
783
783
|
stub_const('PWN::AI::Agent::Learning::LEARNING_FILE', File.join(dir, 'learning.jsonl'))
|
|
784
784
|
File.write(File.join(dir, 'learning.jsonl'), "{}\n")
|
|
785
785
|
7.times { |i| File.write(File.join(dir, "learning.jsonl.bak-#{i}"), 'x') }
|
|
786
786
|
out = PWN::AI::Agent::Learning.compact!(max_baks: 5)
|
|
787
|
-
expect(Dir[File.join(dir, '*.bak*')].length).to eq(
|
|
788
|
-
expect(out
|
|
787
|
+
expect(Dir[File.join(dir, '*.bak*')].length).to eq(7)
|
|
788
|
+
expect(out).to include(pruned: 0, max_baks: 7, scope: 'global')
|
|
789
789
|
end
|
|
790
790
|
end
|
|
791
791
|
end
|
|
@@ -21,6 +21,11 @@ RSpec.describe PWN::AI::Agent::Loop do
|
|
|
21
21
|
allow(PWN::AI::Agent::Mistakes).to receive(:record)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
+
it 'never adopts cached model destinations absent from the original request' do
|
|
25
|
+
Thread.current[:pwn_loop_deliverables] = { paths: ['/tmp/invented.pdf'] }
|
|
26
|
+
expect(described_class.send(:declared_deliverables, request: 'Generate a report')).to eq([])
|
|
27
|
+
end
|
|
28
|
+
|
|
24
29
|
it 'does not let source read evidence bypass the destination contract' do
|
|
25
30
|
Dir.mktmpdir do |dir|
|
|
26
31
|
source = File.join(dir, 'source.md')
|