pwn 0.5.783 → 0.5.785
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.ruby-version +1 -1
- data/Gemfile +8 -8
- data/README.md +37 -0
- data/Rakefile +19 -1
- data/bin/pwn +2 -1
- data/bin/pwn_setup +1 -1
- data/bin/pwn_web_cache_deception +35 -7
- data/build_gem.sh +1 -1
- data/documentation/AI-Integration.md +7 -5
- data/documentation/Agent-Tool-Registry.md +2 -2
- data/documentation/Backup-Retention.md +82 -0
- data/documentation/Configuration.md +10 -6
- data/documentation/Contributing.md +1 -1
- data/documentation/Cron.md +64 -35
- data/documentation/Diagrams.md +2 -2
- data/documentation/General-PWN-Usage.md +4 -3
- data/documentation/Home.md +6 -5
- data/documentation/How-PWN-Works.md +7 -6
- data/documentation/Installation.md +254 -10
- data/documentation/Mission-Cancellation.md +134 -0
- data/documentation/Operability.md +17 -0
- data/documentation/Persistence.md +8 -6
- data/documentation/Plugin-Degradation.md +12 -0
- data/documentation/Plugins.md +3 -2
- data/documentation/Reinforcement-Learning.md +5 -6
- data/documentation/Runtime-Source-Protection.md +162 -0
- data/documentation/Transparent-Browser.md +11 -0
- data/documentation/Troubleshooting.md +3 -2
- data/documentation/What-is-PWN.md +2 -2
- data/documentation/YubiKey-Vaults.md +183 -0
- data/documentation/YubiKey.md +339 -0
- data/documentation/diagrams/cron-scheduling.svg +130 -128
- data/documentation/diagrams/dot/cron-scheduling.dot +5 -5
- data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
- data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
- data/documentation/diagrams/persistence-filesystem.svg +172 -142
- data/documentation/diagrams/plugin-ecosystem.svg +64 -64
- data/documentation/pwn-ai-Agent.md +10 -4
- data/etc/default_cron/session_cleanup.rb +1245 -22
- data/etc/default_skills/pwn/ai/agent/mission_namespace/SKILL.md +21 -0
- data/etc/default_skills/pwn/ai/agent/tools/browser_act/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +27 -26
- data/etc/default_skills/pwn/ai/agent/tools/recon_run/SKILL.md +25 -15
- data/etc/default_skills/pwn/ai/agent/tools/workspace_tool/SKILL.md +24 -0
- data/etc/default_skills/pwn/ai/server_tools/SKILL.md +25 -0
- data/etc/default_skills/pwn/backups/SKILL.md +54 -0
- data/etc/default_skills/pwn/plugins/recon/SKILL.md +12 -1
- data/etc/default_skills/pwn/plugins/yubikey/SKILL.md +157 -0
- data/etc/default_skills/pwn/plugins/yubikey/fido/SKILL.md +189 -0
- data/etc/default_skills/pwn/plugins/yubikey/hsmauth/SKILL.md +83 -0
- data/etc/default_skills/pwn/plugins/yubikey/management/SKILL.md +80 -0
- data/etc/default_skills/pwn/plugins/yubikey/oath/SKILL.md +115 -0
- data/etc/default_skills/pwn/plugins/yubikey/openpgp/SKILL.md +144 -0
- data/etc/default_skills/pwn/plugins/yubikey/otp/SKILL.md +97 -0
- data/etc/default_skills/pwn/plugins/yubikey/piv/SKILL.md +110 -0
- data/etc/default_skills/pwn/plugins/yubikey/protocol/SKILL.md +21 -0
- data/etc/default_skills/pwn/plugins/yubikey/security_domain/SKILL.md +75 -0
- data/etc/default_skills/pwn/plugins/yubikey/security_domain/scp/SKILL.md +46 -0
- data/etc/default_skills/pwn/plugins/yubikey/transport/SKILL.md +98 -0
- data/etc/default_skills/pwn/redaction/SKILL.md +13 -1
- data/lib/pwn/ai/agent/dispatch.rb +26 -15
- data/lib/pwn/ai/agent/extrospection.rb +6 -7
- data/lib/pwn/ai/agent/learning.rb +62 -25
- data/lib/pwn/ai/agent/loop.rb +17 -1
- data/lib/pwn/ai/agent/mission_namespace.rb +331 -0
- data/lib/pwn/ai/agent/policy.rb +5 -0
- data/lib/pwn/ai/agent/policy_evaluation.rb +8 -8
- data/lib/pwn/ai/agent/prompt_builder.rb +13 -2
- data/lib/pwn/ai/agent/request_runtime.rb +122 -18
- data/lib/pwn/ai/agent/reward.rb +5 -3
- data/lib/pwn/ai/agent/skill_consolidation.rb +3 -2
- data/lib/pwn/ai/agent/skill_review.rb +9 -5
- data/lib/pwn/ai/agent/swarm.rb +35 -16
- data/lib/pwn/ai/agent/tools/browser_act.rb +32 -0
- data/lib/pwn/ai/agent/tools/loot_query.rb +11 -1
- data/lib/pwn/ai/agent/tools/recon_run.rb +11 -1
- data/lib/pwn/ai/agent/tools/workspace_tool.rb +29 -0
- data/lib/pwn/ai/agent/turn_finalizer.rb +6 -2
- data/lib/pwn/ai/agent.rb +1 -0
- data/lib/pwn/ai/anthropic.rb +8 -33
- data/lib/pwn/ai/cli.rb +34 -5
- data/lib/pwn/ai/gemini.rb +1 -0
- data/lib/pwn/ai/grok.rb +39 -28
- data/lib/pwn/ai/ollama.rb +4 -2
- data/lib/pwn/ai/open_ai.rb +11 -32
- data/lib/pwn/ai/open_web_ui.rb +62 -4
- data/lib/pwn/ai/router.rb +53 -3
- data/lib/pwn/ai/server_tools.rb +231 -0
- data/lib/pwn/ai.rb +1 -0
- data/lib/pwn/backups.rb +347 -0
- data/lib/pwn/config.rb +70 -39
- data/lib/pwn/cron.rb +120 -49
- data/lib/pwn/migrate.rb +150 -68
- data/lib/pwn/module_skills.rb +7 -0
- data/lib/pwn/plugins/ai_sandbox.rb +538 -1
- data/lib/pwn/plugins/authentication_helper.rb +74 -4
- data/lib/pwn/plugins/basic_auth.rb +2 -1
- data/lib/pwn/plugins/binary_parser.rb +5 -4
- data/lib/pwn/plugins/ghidra.rb +4 -3
- data/lib/pwn/plugins/google_workspace.rb +4 -21
- data/lib/pwn/plugins/jobs.rb +1 -1
- data/lib/pwn/plugins/log.rb +21 -3
- data/lib/pwn/plugins/preflight_checker.rb +34 -9
- data/lib/pwn/plugins/recon.rb +4 -1
- data/lib/pwn/plugins/repl/ai.rb +305 -27
- data/lib/pwn/plugins/repl/ai_console.rb +273 -89
- data/lib/pwn/plugins/repl/ai_swarm.rb +181 -62
- data/lib/pwn/plugins/repl/mesh.rb +3 -16
- data/lib/pwn/plugins/repl/vault.rb +4 -15
- data/lib/pwn/plugins/repl.rb +1 -216
- data/lib/pwn/plugins/sandbox/driver.rb +4 -0
- data/lib/pwn/plugins/transparent_browser.rb +215 -0
- data/lib/pwn/plugins/vault.rb +412 -135
- data/lib/pwn/plugins/yubikey/fido.rb +1288 -0
- data/lib/pwn/plugins/yubikey/hsmauth.rb +439 -0
- data/lib/pwn/plugins/yubikey/management.rb +686 -0
- data/lib/pwn/plugins/yubikey/oath.rb +766 -0
- data/lib/pwn/plugins/yubikey/openpgp.rb +1063 -0
- data/lib/pwn/plugins/yubikey/otp.rb +897 -0
- data/lib/pwn/plugins/yubikey/piv.rb +1315 -0
- data/lib/pwn/plugins/yubikey/protocol.rb +341 -0
- data/lib/pwn/plugins/yubikey/security_domain/scp.rb +482 -0
- data/lib/pwn/plugins/yubikey/security_domain.rb +464 -0
- data/lib/pwn/plugins/yubikey/transport.rb +1017 -0
- data/lib/pwn/plugins/yubikey.rb +844 -0
- data/lib/pwn/plugins.rb +1 -0
- data/lib/pwn/redaction.rb +69 -12
- data/lib/pwn/session_trace.rb +4 -0
- data/lib/pwn/sessions.rb +58 -16
- data/lib/pwn/setup.rb +123 -41
- data/lib/pwn/version.rb +1 -1
- data/lib/pwn/www/app_cobalt_io.rb +16 -5
- data/lib/pwn/www/bug_crowd.rb +16 -5
- data/lib/pwn/www/coinbase_pro.rb +16 -5
- data/lib/pwn/www/github.rb +30 -14
- data/lib/pwn/www/paypal.rb +15 -4
- data/lib/pwn/www/synack.rb +16 -5
- data/lib/pwn/www/twitter.rb +17 -6
- data/lib/pwn.rb +1 -0
- data/scripts/build_gate.rb +58 -0
- data/spec/documentation/installation_md_spec.rb +1 -1
- data/spec/fixtures/yubikey/apdu_frames.json +162 -0
- data/spec/integration/authentication_helper_oath_spec.rb +117 -0
- data/spec/integration/build_gate_spec.rb +73 -0
- data/spec/integration/controller_spill_spec.rb +48 -0
- data/spec/integration/harness_process_spec.rb +97 -0
- data/spec/integration/mission_cancel_spec.rb +79 -0
- data/spec/integration/mission_curses_pty_spec.rb +44 -0
- data/spec/integration/persistence_roundtrip_spec.rb +1 -7
- data/spec/integration/readme_security_spec.rb +47 -0
- data/spec/integration/web_cache_deception_yubikey_spec.rb +159 -0
- data/spec/lib/pwn/ai/agent/dispatch_spec.rb +6 -3
- data/spec/lib/pwn/ai/agent/dispatch_vault_safety_spec.rb +86 -0
- data/spec/lib/pwn/ai/agent/injection_guard_spec.rb +2 -0
- data/spec/lib/pwn/ai/agent/learning_spec.rb +4 -4
- data/spec/lib/pwn/ai/agent/loop_artifacts_spec.rb +5 -0
- data/spec/lib/pwn/ai/agent/loop_spec.rb +104 -29
- data/spec/lib/pwn/ai/agent/mission_namespace_spec.rb +138 -0
- data/spec/lib/pwn/ai/agent/policy_spec.rb +27 -0
- data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +48 -0
- data/spec/lib/pwn/ai/agent/shell_search_effect_spec.rb +44 -0
- data/spec/lib/pwn/ai/agent/solve_spec.rb +6 -4
- data/spec/lib/pwn/ai/agent/tools/browser_act_spec.rb +44 -0
- data/spec/lib/pwn/ai/agent/tools/loot_query_yubikey_spec.rb +55 -0
- data/spec/lib/pwn/ai/agent/tools/recon_run_yubikey_spec.rb +43 -0
- data/spec/lib/pwn/ai/agent/tools/workspace_tool_spec.rb +20 -0
- data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +7 -0
- data/spec/lib/pwn/ai/anthropic_vault_enrollment_spec.rb +37 -0
- data/spec/lib/pwn/ai/cli_spec.rb +6 -9
- data/spec/lib/pwn/ai/oauth_vault_persistence_spec.rb +195 -0
- data/spec/lib/pwn/ai/open_ai_spec.rb +2 -2
- data/spec/lib/pwn/ai/open_web_ui_workspace_spec.rb +36 -0
- data/spec/lib/pwn/ai/router_spec.rb +31 -3
- data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
- data/spec/lib/pwn/backups_spec.rb +114 -0
- data/spec/lib/pwn/config_redaction_yubikey_spec.rb +28 -0
- data/spec/lib/pwn/config_yubikey_spec.rb +92 -0
- data/spec/lib/pwn/cron_spec.rb +113 -6
- data/spec/lib/pwn/migrate_spec.rb +108 -5
- data/spec/lib/pwn/migrate_yubikey_spec.rb +128 -0
- data/spec/lib/pwn/module_skills_spec.rb +8 -0
- data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +230 -0
- data/spec/lib/pwn/plugins/authentication_helper_native_yubikey_spec.rb +40 -0
- data/spec/lib/pwn/plugins/authentication_helper_yubikey_spec.rb +90 -0
- data/spec/lib/pwn/plugins/capability_broker/daemon_spec.rb +1 -1
- data/spec/lib/pwn/plugins/google_workspace_spec.rb +17 -35
- data/spec/lib/pwn/plugins/log_spec.rb +7 -0
- data/spec/lib/pwn/plugins/log_yubikey_spec.rb +47 -0
- data/spec/lib/pwn/plugins/preflight_checker_native_yubikey_spec.rb +19 -0
- data/spec/lib/pwn/plugins/preflight_checker_yubikey_spec.rb +55 -0
- data/spec/lib/pwn/plugins/recon_yubikey_spec.rb +43 -0
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +125 -78
- data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
- data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +30 -16
- data/spec/lib/pwn/plugins/repl/ai_vault_edit_yubikey_spec.rb +103 -0
- data/spec/lib/pwn/plugins/repl/ai_yubikey_spec.rb +48 -0
- data/spec/lib/pwn/plugins/repl/mesh_vault_persistence_spec.rb +149 -0
- data/spec/lib/pwn/plugins/repl/vault_yaml_serialization_spec.rb +11 -0
- data/spec/lib/pwn/plugins/repl/vault_yubikey_spec.rb +39 -0
- data/spec/lib/pwn/plugins/repl_spec.rb +56 -5
- data/spec/lib/pwn/plugins/transparent_browser_jev_spec.rb +46 -0
- data/spec/lib/pwn/plugins/vault_native_yubikey_spec.rb +29 -0
- data/spec/lib/pwn/plugins/vault_publication_safety_spec.rb +258 -0
- data/spec/lib/pwn/plugins/vault_store_safety_spec.rb +241 -0
- data/spec/lib/pwn/plugins/vault_yubikey_safety_spec.rb +168 -0
- data/spec/lib/pwn/plugins/vault_yubikey_spec.rb +128 -0
- data/spec/lib/pwn/plugins/vault_yubikey_vector_spec.rb +45 -0
- data/spec/lib/pwn/plugins/yubikey/fido_spec.rb +854 -0
- data/spec/lib/pwn/plugins/yubikey/hsmauth_spec.rb +235 -0
- data/spec/lib/pwn/plugins/yubikey/management_spec.rb +277 -0
- data/spec/lib/pwn/plugins/yubikey/oath_spec.rb +349 -0
- data/spec/lib/pwn/plugins/yubikey/openpgp_spec.rb +810 -0
- data/spec/lib/pwn/plugins/yubikey/otp_spec.rb +337 -0
- data/spec/lib/pwn/plugins/yubikey/piv_spec.rb +646 -0
- data/spec/lib/pwn/plugins/yubikey/protocol_secure_spec.rb +28 -0
- data/spec/lib/pwn/plugins/yubikey/protocol_spec.rb +149 -0
- data/spec/lib/pwn/plugins/yubikey/protocol_vectors_spec.rb +18 -0
- data/spec/lib/pwn/plugins/yubikey/security_domain/scp_spec.rb +256 -0
- data/spec/lib/pwn/plugins/yubikey/security_domain/support.rb +153 -0
- data/spec/lib/pwn/plugins/yubikey/security_domain_spec.rb +296 -0
- data/spec/lib/pwn/plugins/yubikey/transport_spec.rb +760 -0
- data/spec/lib/pwn/plugins/yubikey_security_spec.rb +260 -0
- data/spec/lib/pwn/plugins/yubikey_spec.rb +209 -0
- data/spec/lib/pwn/plugins/yubikey_terminal_spec.rb +147 -0
- data/spec/lib/pwn/redaction_spec.rb +13 -10
- data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
- data/spec/lib/pwn/session_cleanup_concurrency_spec.rb +201 -0
- data/spec/lib/pwn/session_cleanup_default_spec.rb +167 -2
- data/spec/lib/pwn/session_cleanup_legacy_spec.rb +101 -0
- data/spec/lib/pwn/session_cleanup_payload_spec.rb +335 -2
- data/spec/lib/pwn/session_cleanup_recovery_spec.rb +403 -0
- data/spec/lib/pwn/sessions_spec.rb +112 -0
- data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
- data/spec/lib/pwn/setup_spec.rb +3 -1
- data/spec/lib/pwn/setup_vault_yubikey_spec.rb +61 -0
- data/spec/lib/pwn/setup_yubikey_spec.rb +40 -0
- data/spec/lib/pwn/www/login_yubikey_spec.rb +273 -0
- data/spec/spec_helper.rb +15 -0
- data/spec/support/gate_failure_formatter.rb +14 -0
- data/spec/support/harness/cli_faults.rb +13 -9
- data/spec/support/harness/controller_spill.rb +26 -0
- data/spec/support/harness/controller_state.rb +54 -0
- data/spec/support/harness/evaluate.rb +12 -1
- data/spec/support/harness/experiments.rb +1 -1
- data/spec/support/harness/fake_provider.rb +22 -6
- data/spec/support/harness/fault_preload.rb +3 -1
- data/spec/support/harness/mission_console.rb +54 -0
- data/spec/support/harness/mission_curses_pty.rb +41 -0
- data/spec/support/harness/mission_dispatch.rb +30 -0
- data/spec/support/harness/mission_fixture_preload.rb +24 -0
- data/spec/support/harness/mission_job_lifetime.rb +25 -0
- data/spec/support/harness/mission_legacy.rb +25 -0
- data/spec/support/harness/mission_normal_exit.rb +34 -0
- data/spec/support/harness/mission_swarm.rb +23 -0
- data/spec/support/harness/mission_unsupported.rb +29 -0
- data/spec/support/harness/package_acceptance.rb +33 -5
- data/spec/support/harness/session_acceptance.rb +4 -4
- data/spec/support/harness/swarm_acceptance.rb +1 -1
- data/spec/support/harness/swarm_control.rb +44 -0
- data/spec/support/mission_namespace.rb +14 -0
- data/spec/support/mission_pipe_queue.rb +33 -0
- data/third_party/pwn_rdoc.jsonl +599 -15
- metadata +140 -17
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
require 'stringio'
|
|
6
|
+
|
|
7
|
+
RSpec.describe PWN::Setup, 'hardware vault doctor' do
|
|
8
|
+
around do |example|
|
|
9
|
+
Dir.mktmpdir('setup-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
|
+
@pwn_root = File.join(@root, '.pwn')
|
|
18
|
+
FileUtils.mkdir_p(@pwn_root)
|
|
19
|
+
stub_const('PWN::Env', {})
|
|
20
|
+
stub_const('PWN::Migrate::PWN_ROOT', @pwn_root)
|
|
21
|
+
stub_const('PWN::Migrate::SCHEMA_FILE', File.join(@pwn_root, '.schema'))
|
|
22
|
+
stub_const('PWN::Setup::NATIVE_GEMS', {})
|
|
23
|
+
stub_const('PWN::Setup::TOOLCHAIN', {})
|
|
24
|
+
stub_const('PWN::Setup::NATIVE_LIBRARIES', {})
|
|
25
|
+
allow(described_class).to receive(:pkg_manager).and_return(key: :fixture)
|
|
26
|
+
allow(described_class).to receive(:detect_os_attr).and_return('fixture')
|
|
27
|
+
stub_const('PWN::Plugins::YubiKey', double('synthetic YubiKey'))
|
|
28
|
+
allow(PWN::Plugins::YubiKey).to receive(:challenge_response) do |opts|
|
|
29
|
+
OpenSSL::HMAC.digest('SHA1', 'synthetic-slot-secret', opts[:challenge])
|
|
30
|
+
end
|
|
31
|
+
@path = File.join(@pwn_root, 'pwn.yaml')
|
|
32
|
+
File.binwrite(@path, PWN::Plugins::Vault.encrypt_data(data: YAML.dump(ai: { active: 'ollama' }), yubikey: { slot: 2 }))
|
|
33
|
+
@io = StringIO.new
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'uses Config driver paths consistently in setup and the migration state verifier' do
|
|
37
|
+
custom = File.join(@root, 'custom-vault')
|
|
38
|
+
File.rename(@path, custom)
|
|
39
|
+
PWN::Env[:driver_opts] = { pwn_env_path: custom, pwn_dec_path: File.join(@root, 'custom-sidecar') }
|
|
40
|
+
expect(PWN::Plugins::YubiKey).not_to receive(:challenge_response)
|
|
41
|
+
report = described_class.check(io: @io)
|
|
42
|
+
expect(report[:ok]).to be(true)
|
|
43
|
+
expect(report.dig(:state, :files).find { |row| row[:rel] == 'pwn.yaml' }).to include(ok: true, path: custom)
|
|
44
|
+
expect(@io.string).to include('YubiKey', 'no software decryptor required')
|
|
45
|
+
expect(File).not_to exist(@path)
|
|
46
|
+
expect(File).not_to exist(PWN::Env.dig(:driver_opts, :pwn_dec_path))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it 'recognizes hardware vaults without a missing-decryptor warning or a device operation' do
|
|
50
|
+
bytes = File.binread(@path)
|
|
51
|
+
expect(PWN::Plugins::YubiKey).not_to receive(:challenge_response)
|
|
52
|
+
expect(PWN::Plugins::Vault).not_to receive(:create)
|
|
53
|
+
report = described_class.check(io: @io)
|
|
54
|
+
expect(report[:ok]).to be(true)
|
|
55
|
+
expect(report.dig(:state, :incompatible)).to be_empty
|
|
56
|
+
expect(@io.string).to include('YubiKey', 'no software decryptor required')
|
|
57
|
+
expect(@io.string).not_to include('decryptor MISSING')
|
|
58
|
+
expect(File.binread(@path)).to eq(bytes)
|
|
59
|
+
expect(Dir.children(@pwn_root)).to eq(['pwn.yaml'])
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe PWN::Setup, 'optional native YubiKey libraries' do
|
|
6
|
+
before do
|
|
7
|
+
allow(described_class).to receive(:check).and_return(ok: true)
|
|
8
|
+
allow(described_class).to receive(:gem_loadable?).and_return(true)
|
|
9
|
+
expect(described_class).not_to receive(:system)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it 'replaces executable provisioning with optional HIDAPI and PC/SC libraries' do
|
|
13
|
+
expect(described_class::TOOLCHAIN).not_to have_key('ykman')
|
|
14
|
+
expect(described_class::PROFILES.fetch(:hardware).fetch(:bins)).not_to include('ykman')
|
|
15
|
+
expect(described_class::PROFILES.fetch(:full).fetch(:bins)).not_to include('ykman')
|
|
16
|
+
expect(described_class::PROFILES.fetch(:hardware).fetch(:libs)).to eq(%w[hidapi pcsc])
|
|
17
|
+
expect(described_class::PROFILES.fetch(:full).fetch(:libs)).to eq(described_class::NATIVE_LIBRARIES.keys)
|
|
18
|
+
expect(Array(described_class::PROFILES.fetch(:core)[:libs])).to be_empty
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
{ apt: %w[libhidapi-hidraw0 libpcsclite1 pcscd libccid],
|
|
22
|
+
dnf: %w[hidapi pcsc-lite-libs pcsc-lite pcsc-lite-ccid],
|
|
23
|
+
pacman: %w[hidapi pcsclite ccid], brew: %w[hidapi], port: %w[hidapi] }.each do |manager, libraries|
|
|
24
|
+
it "plans native host packages for #{manager} without a Python or CLI backend" do
|
|
25
|
+
allow(described_class).to receive(:pkg_manager).and_return(key: manager, install: 'install-package')
|
|
26
|
+
result = described_class.deps(profile: :hardware, dry_run: true, io: StringIO.new, distro: :fixture, version: 'test')
|
|
27
|
+
commands = result.fetch(:ran).map { |row| row.fetch(:cmd) }
|
|
28
|
+
libraries.each { |package| expect(commands).to include("install-package #{package}") }
|
|
29
|
+
expect(commands.join('\n')).not_to match(/ykman|yubikey-manager|python|\bpip(?:[23x])?\b/)
|
|
30
|
+
expect(result.fetch(:ran)).to all(include(dry_run: true))
|
|
31
|
+
expect(commands.join('\n')).not_to match(/pcsc|ccid/) if %i[brew port].include?(manager)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it 'does not install hardware packages in the core profile' do
|
|
36
|
+
allow(described_class).to receive(:pkg_manager).and_return(key: :apt, install: 'install-package')
|
|
37
|
+
result = described_class.deps(profile: :core, dry_run: true, io: StringIO.new, distro: :fixture, version: 'test')
|
|
38
|
+
expect(result.fetch(:ran).map { |row| row.fetch(:cmd) }.join('\n')).not_to match(/hidapi|pcsclite|pcscd|ccid|ykman/)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'tty-prompt'
|
|
5
|
+
|
|
6
|
+
RSpec.shared_context 'offline MFA browser' do |selectors|
|
|
7
|
+
let(:browser) { double('offline browser') }
|
|
8
|
+
let(:browser_obj) { { browser: browser } }
|
|
9
|
+
let(:credential_field) { double('credential field', set: nil, present?: true) }
|
|
10
|
+
let(:otp_field) { double('OTP field', present?: true, exist?: true) }
|
|
11
|
+
let(:absent_field) { double('absent field', present?: false, exist?: false) }
|
|
12
|
+
let(:submit) { double('submit control', exist?: true, present?: true) }
|
|
13
|
+
let(:yubikey) { { account: 'Example:tester@example.invalid', confirm: true, serial: '123456', device_path: '/fixture/native-device', timeout: 30 }.freeze }
|
|
14
|
+
let(:login_opts) { { browser_obj: browser_obj, username: 'tester@example.invalid', password: 'fake-password', yubikey: yubikey } }
|
|
15
|
+
|
|
16
|
+
before do
|
|
17
|
+
@authenticated = false
|
|
18
|
+
@otp_written = false
|
|
19
|
+
@form_ready = false
|
|
20
|
+
stub_const('PWN::Plugins::YubiKey', Module.new)
|
|
21
|
+
allow(PWN::Plugins::YubiKey).to receive(:oath_code) do
|
|
22
|
+
raise 'OATH requested before the MFA form was ready' unless @form_ready
|
|
23
|
+
|
|
24
|
+
'012345'
|
|
25
|
+
end
|
|
26
|
+
allow(TTY::Prompt).to receive(:new).and_raise('unexpected interactive prompt')
|
|
27
|
+
allow(browser).to receive(:goto)
|
|
28
|
+
allow(browser).to receive(:url) { @authenticated ? selectors[:success] : selectors[:challenge] }
|
|
29
|
+
allow(browser).to receive(:text_field).and_return(credential_field)
|
|
30
|
+
allow(credential_field).to receive(:wait_until).and_return(credential_field)
|
|
31
|
+
allow(browser).to receive(:text_field).with(selectors[:otp]).and_return(otp_field)
|
|
32
|
+
Array(selectors[:absent]).each do |selector|
|
|
33
|
+
allow(browser).to receive(:text_field).with(selector).and_return(absent_field)
|
|
34
|
+
end
|
|
35
|
+
allow(otp_field).to receive(:wait_until) do
|
|
36
|
+
@form_ready = true
|
|
37
|
+
otp_field
|
|
38
|
+
end
|
|
39
|
+
allow(otp_field).to receive(:set) { @otp_written = true }
|
|
40
|
+
allow(browser).to receive_messages(button: submit, input: submit, span: submit)
|
|
41
|
+
allow(submit).to receive(:wait_until).and_return(submit)
|
|
42
|
+
allow(submit).to receive(:click)
|
|
43
|
+
allow(submit).to receive(:click!) { @authenticated = true if @otp_written }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
RSpec.shared_examples 'YubiKey browser MFA' do |selectors|
|
|
48
|
+
include_context 'offline MFA browser', selectors
|
|
49
|
+
include_examples 'manual browser MFA'
|
|
50
|
+
|
|
51
|
+
it 'documents explicit consent in the YubiKey example and explains native OATH effects' do
|
|
52
|
+
expect { described_class.help }.to output(/Example yubikey: \{ account: 'EXACT_OATH_NAME', confirm: true.*HOTP.*authenticat/m).to_stdout
|
|
53
|
+
expect { described_class.help }.not_to output(/ykman/).to_stdout
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
[nil, false].each do |mfa|
|
|
57
|
+
it "types a leading-zero OATH code at the MFA form when mfa is #{mfa.inspect}" do
|
|
58
|
+
login_opts[:mfa] = mfa unless mfa.nil?
|
|
59
|
+
expect(otp_field).to receive(:set).with('012345') { @otp_written = true }
|
|
60
|
+
expect(described_class.login(login_opts)).to equal(browser_obj)
|
|
61
|
+
expect(PWN::Plugins::YubiKey).to have_received(:oath_code).with(yubikey).once
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it 'does not read a YubiKey when login completes without an MFA challenge' do
|
|
66
|
+
@authenticated = true
|
|
67
|
+
expect(PWN::Plugins::YubiKey).not_to receive(:oath_code)
|
|
68
|
+
expect(otp_field).not_to receive(:set)
|
|
69
|
+
expect(described_class.login(login_opts)).to equal(browser_obj)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it 'does not read a YubiKey if the MFA field never becomes present' do
|
|
73
|
+
allow(otp_field).to receive(:wait_until).and_raise(IOError, 'MFA form unavailable')
|
|
74
|
+
expect(PWN::Plugins::YubiKey).not_to receive(:oath_code)
|
|
75
|
+
expect(otp_field).not_to receive(:set)
|
|
76
|
+
expect { described_class.login(login_opts) }.to raise_error(IOError, 'MFA form unavailable')
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it 'propagates a YubiKey failure without typing a token or prompting' do
|
|
80
|
+
allow(PWN::Plugins::YubiKey).to receive(:oath_code).and_raise(IOError, 'device unavailable')
|
|
81
|
+
expect(otp_field).not_to receive(:set)
|
|
82
|
+
expect { described_class.login(login_opts) }.to raise_error(IOError, 'device unavailable')
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
RSpec.shared_examples 'manual browser MFA' do
|
|
87
|
+
it 'preserves the manual MFA prompt without a YubiKey option' do
|
|
88
|
+
login_opts.delete(:yubikey)
|
|
89
|
+
login_opts[:mfa] = true
|
|
90
|
+
prompt = instance_double(TTY::Prompt)
|
|
91
|
+
allow(TTY::Prompt).to receive(:new).and_return(prompt)
|
|
92
|
+
expect(prompt).to receive(:ask).with('enter mfa token: ').and_return('012345')
|
|
93
|
+
expect(otp_field).to receive(:set).with('012345') { @otp_written = true }
|
|
94
|
+
expect(PWN::Plugins::YubiKey).not_to receive(:oath_code)
|
|
95
|
+
expect(described_class.login(login_opts)).to equal(browser_obj)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it 'preserves disabled MFA when neither a token source nor mfa is selected' do
|
|
99
|
+
login_opts.delete(:yubikey)
|
|
100
|
+
expect(PWN::Plugins::YubiKey).not_to receive(:oath_code)
|
|
101
|
+
expect(otp_field).not_to receive(:set)
|
|
102
|
+
expect(described_class.login(login_opts)).to equal(browser_obj)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
RSpec.shared_examples 'MFA source validation' do
|
|
107
|
+
[nil, false, 'true'].each do |confirm|
|
|
108
|
+
it "rejects missing or nonliteral OATH consent #{confirm.inspect} before password prompts or browser navigation" do
|
|
109
|
+
login_opts.delete(:password)
|
|
110
|
+
unconfirmed = yubikey.except(:confirm)
|
|
111
|
+
unconfirmed[:confirm] = confirm unless confirm.nil?
|
|
112
|
+
expect(browser).not_to receive(:goto)
|
|
113
|
+
expect(PWN::Plugins::AuthenticationHelper).not_to receive(:mask_password)
|
|
114
|
+
expect(PWN::Plugins::YubiKey).not_to receive(:oath_code)
|
|
115
|
+
expect { described_class.login(login_opts.merge(yubikey: unconfirmed)) }.to raise_error(ArgumentError, /confirm: true/)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it 'documents the optional YubiKey configuration and explicit-token conflict' do
|
|
120
|
+
expect { described_class.help }.to output(/yubikey:.*exact.*account.*serial.*reader.*device_path.*connection.*timeout.*conflict/m).to_stdout
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
[{ mfa: '654321' }, { mfa_token: '654321' }, { mfa: '654321', mfa_token: ' ' }].each do |conflict|
|
|
124
|
+
it "rejects YubiKey with explicit token #{conflict.keys.inspect} before login or prompting" do
|
|
125
|
+
login_opts.delete(:password)
|
|
126
|
+
expect(browser).not_to receive(:goto)
|
|
127
|
+
expect(PWN::Plugins::AuthenticationHelper).not_to receive(:mask_password)
|
|
128
|
+
expect(PWN::Plugins::YubiKey).not_to receive(:oath_code)
|
|
129
|
+
expect { described_class.login(login_opts.merge(conflict)) }.to raise_error(ArgumentError, /yubikey.*(?:mfa|token)/i)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it 'rejects malformed YubiKey configuration before submitting a password' do
|
|
134
|
+
expect(browser).not_to receive(:goto)
|
|
135
|
+
expect(PWN::Plugins::YubiKey).not_to receive(:oath_code)
|
|
136
|
+
expect { described_class.login(login_opts.merge(yubikey: {})) }.to raise_error(ArgumentError, /account/)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
RSpec.describe PWN::WWW::GitHub do
|
|
141
|
+
include_examples 'MFA source validation'
|
|
142
|
+
include_examples 'YubiKey browser MFA',
|
|
143
|
+
otp: { name: 'app_otp' },
|
|
144
|
+
challenge: 'https://github.com/sessions/two-factor',
|
|
145
|
+
success: 'https://github.com/'
|
|
146
|
+
|
|
147
|
+
[
|
|
148
|
+
{ mfa: '012345' },
|
|
149
|
+
{ mfa_token: '012345' },
|
|
150
|
+
{ mfa: '654321', mfa_token: '012345' }
|
|
151
|
+
].each do |token_opts|
|
|
152
|
+
it "preserves explicit tokens and mfa_token precedence for #{token_opts.keys.inspect}" do
|
|
153
|
+
login_opts.delete(:yubikey)
|
|
154
|
+
expect(PWN::Plugins::YubiKey).not_to receive(:oath_code)
|
|
155
|
+
expect(otp_field).to receive(:set).with('012345') { @otp_written = true }
|
|
156
|
+
expect(described_class.login(login_opts.merge(token_opts))).to equal(browser_obj)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
[{ id: 'app_totp' }, { name: 'otp' }].each do |selector|
|
|
161
|
+
it "supports the alternate authenticator field #{selector.inspect}" do
|
|
162
|
+
[{ name: 'app_otp' }, { id: 'app_totp' }, { name: 'otp' }].each do |absent|
|
|
163
|
+
allow(browser).to receive(:text_field).with(absent).and_return(absent_field)
|
|
164
|
+
end
|
|
165
|
+
allow(browser).to receive(:text_field).with(selector).and_return(otp_field)
|
|
166
|
+
expect(otp_field).to receive(:set).with('012345') { @otp_written = true }
|
|
167
|
+
expect(described_class.login(login_opts)).to equal(browser_obj)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
it 'still accepts explicit SMS tokens without YubiKey' do
|
|
172
|
+
login_opts.delete(:yubikey)
|
|
173
|
+
[{ name: 'app_otp' }, { id: 'app_totp' }, { name: 'otp' }].each do |selector|
|
|
174
|
+
allow(browser).to receive(:text_field).with(selector).and_return(absent_field)
|
|
175
|
+
end
|
|
176
|
+
allow(browser).to receive(:text_field).with(name: 'sms_otp').and_return(otp_field)
|
|
177
|
+
expect(otp_field).to receive(:set).with('012345') { @otp_written = true }
|
|
178
|
+
expect(described_class.login(login_opts.merge(mfa_token: '012345'))).to equal(browser_obj)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
it 'consumes an explicit token once then prompts again on a repeated MFA form' do
|
|
182
|
+
login_opts.delete(:yubikey)
|
|
183
|
+
prompt = instance_double(TTY::Prompt)
|
|
184
|
+
allow(TTY::Prompt).to receive(:new).and_return(prompt)
|
|
185
|
+
expect(prompt).to receive(:ask).with('enter mfa token: ').once.and_return('012345')
|
|
186
|
+
expect(otp_field).to(receive(:set).with('654321').ordered { @otp_written = false })
|
|
187
|
+
expect(otp_field).to(receive(:set).with('012345').ordered { @otp_written = true })
|
|
188
|
+
expect(described_class.login(login_opts.merge(mfa_token: '654321'))).to equal(browser_obj)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
it 'refuses to type an OATH code into an SMS-only challenge' do
|
|
192
|
+
%i[app_otp otp].each do |name|
|
|
193
|
+
allow(browser).to receive(:text_field).with(name: name.to_s).and_return(absent_field)
|
|
194
|
+
end
|
|
195
|
+
allow(browser).to receive(:text_field).with(id: 'app_totp').and_return(absent_field)
|
|
196
|
+
allow(browser).to receive(:text_field).with(name: 'sms_otp').and_return(otp_field)
|
|
197
|
+
expect(PWN::Plugins::YubiKey).not_to receive(:oath_code)
|
|
198
|
+
expect(otp_field).not_to receive(:set)
|
|
199
|
+
expect { described_class.login(login_opts) }.to raise_error(ArgumentError, /OATH.*authenticator/)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
RSpec.describe PWN::WWW::Synack do
|
|
204
|
+
include_examples 'MFA source validation'
|
|
205
|
+
include_examples 'YubiKey browser MFA',
|
|
206
|
+
otp: { name: 'authy_token' },
|
|
207
|
+
challenge: 'https://login.synack.com/mfa',
|
|
208
|
+
success: 'https://platform.synack.com'
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
RSpec.describe PWN::WWW::Twitter do
|
|
212
|
+
include_examples 'MFA source validation'
|
|
213
|
+
include_examples 'YubiKey browser MFA',
|
|
214
|
+
otp: { id: 'challenge_response' },
|
|
215
|
+
challenge: 'https://twitter.com/login/challenge',
|
|
216
|
+
success: 'https://twitter.com/home'
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
RSpec.describe PWN::WWW::BugCrowd do
|
|
220
|
+
include_examples 'MFA source validation'
|
|
221
|
+
include_examples 'YubiKey browser MFA',
|
|
222
|
+
otp: { name: 'otp_attempt' },
|
|
223
|
+
challenge: 'https://bugcrowd.com/user/two_factor',
|
|
224
|
+
success: 'https://bugcrowd.com/programs'
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
RSpec.describe PWN::WWW::Paypal do
|
|
228
|
+
include_context 'offline MFA browser',
|
|
229
|
+
otp: { id: 'security-code' },
|
|
230
|
+
challenge: 'https://www.paypal.com/signin/challenge',
|
|
231
|
+
success: 'https://www.paypal.com/myaccount/home'
|
|
232
|
+
include_examples 'MFA source validation'
|
|
233
|
+
include_examples 'manual browser MFA'
|
|
234
|
+
|
|
235
|
+
[nil, false, true].each do |mfa|
|
|
236
|
+
it "rejects unsupported OATH before login or prompting when mfa is #{mfa.inspect}" do
|
|
237
|
+
login_opts.delete(:password)
|
|
238
|
+
login_opts[:mfa] = mfa unless mfa.nil?
|
|
239
|
+
expect(browser).not_to receive(:goto)
|
|
240
|
+
expect(PWN::Plugins::AuthenticationHelper).not_to receive(:mask_password)
|
|
241
|
+
expect(PWN::Plugins::YubiKey).not_to receive(:oath_code)
|
|
242
|
+
expect { described_class.login(login_opts) }.to raise_error(ArgumentError, /PayPal.*OATH.*unsupported.*SMS/)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
it 'documents the SMS-only adapter limitation instead of claiming TOTP support' do
|
|
247
|
+
expect { described_class.help }.to output(/OATH.*unsupported.*SMS/m).to_stdout
|
|
248
|
+
expect { described_class.help }.to output(/unsupported even with confirm: true/m).to_stdout
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
it 'retains the SMS selection in manual mode' do
|
|
252
|
+
login_opts.delete(:yubikey)
|
|
253
|
+
allow(PWN::Plugins::AuthenticationHelper).to receive(:mfa).and_return('012345')
|
|
254
|
+
expect(browser).to receive(:button).with(id: 'btnSelectSoftToken').and_return(submit)
|
|
255
|
+
expect(described_class.login(login_opts.merge(mfa: true))).to equal(browser_obj)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
RSpec.describe PWN::WWW::CoinbasePro do
|
|
260
|
+
include_examples 'MFA source validation'
|
|
261
|
+
include_examples 'YubiKey browser MFA',
|
|
262
|
+
otp: { name: 'token' },
|
|
263
|
+
challenge: 'https://www.coinbase.com/signin/challenge',
|
|
264
|
+
success: 'https://pro.coinbase.com'
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
RSpec.describe PWN::WWW::AppCobaltIO do
|
|
268
|
+
include_examples 'MFA source validation'
|
|
269
|
+
include_examples 'YubiKey browser MFA',
|
|
270
|
+
otp: { id: 'code' },
|
|
271
|
+
challenge: 'https://app.cobalt.io/users/two_factor',
|
|
272
|
+
success: 'https://app.cobalt.io/dashboard'
|
|
273
|
+
end
|
data/spec/spec_helper.rb
CHANGED
|
@@ -35,6 +35,21 @@ RSpec.configure do |config|
|
|
|
35
35
|
config.filter_run_excluding :combo_nation_mcp unless ENV['PWN_TEST_COMBO_NATION_MCP'] == '1'
|
|
36
36
|
config.filter_run_excluding :x86_64_binary unless RbConfig::CONFIG['host_cpu'].to_s.match?(/amd64|x86_64/)
|
|
37
37
|
|
|
38
|
+
config.before(:each) do
|
|
39
|
+
@pwn_spec_backup_root = Dir.mktmpdir('pwn-backup-spec-')
|
|
40
|
+
Thread.current[:pwn_backup_real_home] = Dir.home
|
|
41
|
+
Thread.current[:pwn_backup_root] = @pwn_spec_backup_root
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
config.after(:each) do
|
|
45
|
+
root = @pwn_spec_backup_root
|
|
46
|
+
Thread.current[:pwn_backup_root] = nil
|
|
47
|
+
Thread.current[:pwn_backup_real_home] = nil
|
|
48
|
+
@pwn_spec_backup_root = nil
|
|
49
|
+
# FileUtils.remove_entry calls Dir.children, which examples stub with a fixed path.
|
|
50
|
+
system('rm', '-rf', '--', root) if root.is_a?(String) && root.start_with?('/') && root.include?('/pwn-backup-spec-') && !root.include?('..')
|
|
51
|
+
end
|
|
52
|
+
|
|
38
53
|
if ENV['PWN_RAKE_SPEC'] == '1'
|
|
39
54
|
module SilenceRakeFilterBanner
|
|
40
55
|
def report_filter_message(message)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rspec/core/formatters/base_formatter'
|
|
4
|
+
|
|
5
|
+
# The normal final summary is still printed. Emit failures immediately too,
|
|
6
|
+
# so a later killed example cannot erase the only actionable diagnostic.
|
|
7
|
+
class GateFailureFormatter < RSpec::Core::Formatters::BaseFormatter
|
|
8
|
+
RSpec::Core::Formatters.register self, :example_failed
|
|
9
|
+
|
|
10
|
+
def example_failed(notification)
|
|
11
|
+
output.puts notification.fully_formatted(1)
|
|
12
|
+
output.flush
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -11,11 +11,11 @@ module HarnessCLIFaults
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def self.invoke(root, fixture, prompt, options)
|
|
14
|
-
FileUtils.mkdir_p(
|
|
14
|
+
FileUtils.mkdir_p("#{root}-home")
|
|
15
15
|
path = File.join(root, 'provider.json')
|
|
16
16
|
File.write(path, JSON.generate(fixture))
|
|
17
17
|
source = options[:source_root] || HarnessEvaluation::ROOT
|
|
18
|
-
env = { 'HOME' =>
|
|
18
|
+
env = { 'HOME' => "#{root}-home", 'PATH' => HarnessEvaluation.isolated_path, 'LANG' => 'C.UTF-8',
|
|
19
19
|
'GEM_HOME' => options[:gem_home] || Gem.dir, 'GEM_PATH' => options[:gem_path] || Gem.path.join(File::PATH_SEPARATOR),
|
|
20
20
|
'PWN_HARNESS_FIXTURE' => path, 'PWN_HARNESS_EVENTS' => File.join(root, 'events.jsonl'),
|
|
21
21
|
'PWN_HARNESS_FINGERPRINT' => File.join(root, 'fingerprint.json'), 'PWN_HARNESS_RUNTIME' => File.join(root, 'runtime'),
|
|
@@ -27,9 +27,9 @@ module HarnessCLIFaults
|
|
|
27
27
|
ready_path = fixture[:await_cap_ready] ? env.fetch('PWN_HARNESS_CAP_READY') : nil
|
|
28
28
|
startup = options[:executable] ? 180 : 45
|
|
29
29
|
report = if ready_path
|
|
30
|
-
HarnessEvaluation.capture(env, command, root, fixture.fetch(:wall_seconds,
|
|
30
|
+
HarnessEvaluation.capture(env, command, root, fixture.fetch(:wall_seconds, 120), ready_path: ready_path, startup_seconds: startup)
|
|
31
31
|
else
|
|
32
|
-
HarnessEvaluation.agent_capture(env, command, root, fixture.fetch(:wall_seconds,
|
|
32
|
+
HarnessEvaluation.agent_capture(env, command, root, fixture.fetch(:wall_seconds, 120), options)
|
|
33
33
|
end
|
|
34
34
|
phase = fixture[:resume] ? 'resumed' : 'initial'
|
|
35
35
|
File.write(File.join(root, "#{phase}-process.json"), JSON.pretty_generate(report.merge(command: command)))
|
|
@@ -66,7 +66,9 @@ module HarnessCLIFaults
|
|
|
66
66
|
end
|
|
67
67
|
result = invoke(root, fixture, prompt, options)
|
|
68
68
|
first = result.dup
|
|
69
|
-
|
|
69
|
+
restart_ready = (name == 'checkpoint-restart' && first[:exit_status] == 73) ||
|
|
70
|
+
(name == 'resource-cap-restart' && first[:resource_cap] && first[:ready_observed])
|
|
71
|
+
if restart_ready
|
|
70
72
|
fixture = { resume: true, responses: [write, final] }
|
|
71
73
|
result = invoke(root, fixture, 'continue', options)
|
|
72
74
|
end
|
|
@@ -78,8 +80,8 @@ module HarnessCLIFaults
|
|
|
78
80
|
bytes = File.file?(File.join(root, 'result.txt')) ? File.read(File.join(root, 'result.txt')) : nil
|
|
79
81
|
passed = case name
|
|
80
82
|
when 'compaction-steering'
|
|
81
|
-
result[:exit_status]&.zero? && bytes == "alpha\n" && File.read(File.join(root, 'latest.txt')) == "beta\n" &&
|
|
82
|
-
effects == %w[result-write latest-write] && observed.fetch('effective').include?('Also create latest.txt') &&
|
|
83
|
+
result[:exit_status]&.zero? && bytes == "alpha\n" && File.file?(File.join(root, 'latest.txt')) && File.read(File.join(root, 'latest.txt')) == "beta\n" &&
|
|
84
|
+
effects == %w[result-write latest-write] && observed.fetch('effective', '').include?('Also create latest.txt') &&
|
|
83
85
|
trace.any? { |row| JSON.generate(row['messages']).include?('ENGAGEMENT MEMORY') }
|
|
84
86
|
when 'provider-retry'
|
|
85
87
|
result[:exit_status]&.zero? && bytes == "alpha\n" && effects == ['result-write'] && trace.length == 4
|
|
@@ -90,7 +92,7 @@ module HarnessCLIFaults
|
|
|
90
92
|
(name == 'checkpoint-restart' ? first[:exit_status] == 73 : first[:resource_cap] && first[:ready_observed]) &&
|
|
91
93
|
result[:exit_status] == 1 && effects == ['uncertain-write'] && bytes.nil? &&
|
|
92
94
|
observed.dig('outcome', 'status') != 'complete' &&
|
|
93
|
-
observed.fetch('events').one? { |row| row['phase'] == 'start' && row['tool'] == 'shell' }
|
|
95
|
+
observed.fetch('events', []).one? { |row| row['phase'] == 'start' && row['tool'] == 'shell' }
|
|
94
96
|
end
|
|
95
97
|
outcome = if result[:resource_cap]
|
|
96
98
|
'INCOMPLETE'
|
|
@@ -100,7 +102,9 @@ module HarnessCLIFaults
|
|
|
100
102
|
rows << { id: name, outcome: outcome,
|
|
101
103
|
task_outcome: observed['outcome'], exit_status: result[:exit_status], initial_exit_status: first[:exit_status],
|
|
102
104
|
initial_resource_cap: first[:resource_cap], initial_outcome: first[:resource_cap] ? 'INCOMPLETE' : nil,
|
|
103
|
-
cap_ready_observed: first[:ready_observed], cap_startup_bound_seconds: name == 'resource-cap-restart' ?
|
|
105
|
+
cap_ready_observed: first[:ready_observed], cap_startup_bound_seconds: name == 'resource-cap-restart' ? first[:startup_bound_seconds] : nil,
|
|
106
|
+
initial_process: first.slice(:exit_status, :signal, :resource_cap, :ready_observed, :timeout_phase),
|
|
107
|
+
final_process: result.slice(:exit_status, :signal, :resource_cap, :ready_observed, :timeout_phase),
|
|
104
108
|
effects: effects, repeated_effects: effects.tally.values.sum { |n| n - 1 }, provider_calls: trace.length,
|
|
105
109
|
artifact_root: root, tokens: nil, cost: nil,
|
|
106
110
|
boundary: 'actual CLI with test-injected public Loop runtime/steering controls; provider fake, tools real' }
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pwn'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
source, work, mode = ARGV
|
|
7
|
+
protected_source = PWN::Plugins::AISandbox.protected_roots
|
|
8
|
+
PWN::Plugins::AISandbox.define_singleton_method(:protected_roots) { [source, *protected_source] }
|
|
9
|
+
runtime = PWN::AI::Agent::RequestRuntime.new(request: 'Create a report', session_id: 'spillboundary', artifact_root: File.join(work, 'state'), trace: false, retrieve: false)
|
|
10
|
+
Thread.current[:pwn_request_runtime] = runtime
|
|
11
|
+
spill = if mode == 'transcript'
|
|
12
|
+
File.join(Dir.home, '.pwn', 'artifacts', 'transcripts')
|
|
13
|
+
else
|
|
14
|
+
File.join(work, 'state', 'spillboundary')
|
|
15
|
+
end
|
|
16
|
+
FileUtils.mkdir_p(spill) if mode == 'transcript'
|
|
17
|
+
PWN::AI::Agent::Registry.register(name: 'spill_boundary_fixture', toolset: 'fixture', schema: { name: 'spill_boundary_fixture', parameters: { type: 'object' } }, handler: lambda { |_args|
|
|
18
|
+
Dir.rmdir(spill) if mode == 'transcript'
|
|
19
|
+
File.symlink(source, spill)
|
|
20
|
+
{ redirected: true }
|
|
21
|
+
})
|
|
22
|
+
call = { function: { name: 'spill_boundary_fixture', arguments: '{}' } }
|
|
23
|
+
result = JSON.parse(PWN::AI::Agent::Dispatch.call(tool_call: call))
|
|
24
|
+
runtime.tool_result(call, 'synthetic output ' * 1024)
|
|
25
|
+
PWN::AI::Agent::Loop.send(:spill_tool_body, text: 'synthetic transcript ' * 1024) if mode == 'transcript'
|
|
26
|
+
puts JSON.generate(changed: !Dir.empty?(source), names: Dir.children(source), dispatch_success: result['success'], dispatch_error: JSON.generate(result))
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pwn'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
source, work, mode = ARGV
|
|
7
|
+
original_roots = PWN::Plugins::AISandbox.protected_roots
|
|
8
|
+
PWN::Plugins::AISandbox.define_singleton_method(:protected_roots) { [source, *original_roots] }
|
|
9
|
+
runtime = PWN::AI::Agent::RequestRuntime.new(request: 'Audit controller isolation', session_id: 'stateboundary', artifact_root: File.join(Dir.home, '.pwn', 'requests'), trace: false, retrieve: false)
|
|
10
|
+
Thread.current[:pwn_request_runtime] = runtime
|
|
11
|
+
# Independent finite writer inventory: include stores that do not exist yet,
|
|
12
|
+
# existing state, and writable-looking parent directories beneath HOME.
|
|
13
|
+
directories = %w[sessions artifacts logs learning metrics skills engagements missions runs curriculum finetune]
|
|
14
|
+
files = %w[learning.jsonl lessons.json policy.json policy_traj.jsonl metrics.json mistakes.json preferences.jsonl reward_sentinel.json open_goal.json skill_review.jsonl memory.json memory.archive.json memory.idx curriculum_kpi.jsonl pwn.yaml pwn.yaml.decryptor vault-secrets.json]
|
|
15
|
+
base = File.join(Dir.home, '.pwn')
|
|
16
|
+
directories.each { |name| FileUtils.mkdir_p(File.join(base, name)) }
|
|
17
|
+
if mode == 'aliased'
|
|
18
|
+
path = File.join(base, 'sessions')
|
|
19
|
+
target = File.join(File.dirname(work), 'session-state')
|
|
20
|
+
FileUtils.mv(path, target)
|
|
21
|
+
File.symlink(target, path)
|
|
22
|
+
end
|
|
23
|
+
files.each { |name| File.write(File.join(base, name), '{}') }
|
|
24
|
+
output = File.join(PWN::Plugins::AISandbox.workspace, 'allowed.txt')
|
|
25
|
+
PWN::AI::Agent::Registry.register(name: 'state_boundary_fixture', toolset: 'fixture', schema: { name: 'state_boundary_fixture', parameters: { type: 'object' } }, handler: lambda { |_args|
|
|
26
|
+
attempts = {}
|
|
27
|
+
directories.each do |name|
|
|
28
|
+
path = File.join(base, name, 'redirect')
|
|
29
|
+
attempts[name] = begin
|
|
30
|
+
File.symlink(source, path)
|
|
31
|
+
'writable'
|
|
32
|
+
rescue Errno::EACCES, Errno::EPERM
|
|
33
|
+
'denied'
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
files.each do |name|
|
|
37
|
+
attempts[name] = begin
|
|
38
|
+
File.write(File.join(base, name), 'mutated')
|
|
39
|
+
'writable'
|
|
40
|
+
rescue Errno::EACCES, Errno::EPERM
|
|
41
|
+
'denied'
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
attempts['missing-policy-temp'] = begin
|
|
45
|
+
File.symlink(source, File.join(base, '.policy-host-temp'))
|
|
46
|
+
'writable'
|
|
47
|
+
rescue Errno::EACCES, Errno::EPERM
|
|
48
|
+
'denied'
|
|
49
|
+
end
|
|
50
|
+
File.write(output, 'output remains writable')
|
|
51
|
+
attempts
|
|
52
|
+
})
|
|
53
|
+
result = JSON.parse(PWN::AI::Agent::Dispatch.call(tool_call: { function: { name: 'state_boundary_fixture', arguments: '{}' } }))
|
|
54
|
+
puts JSON.generate(result: result, source_unchanged: Dir.empty?(source), output: File.read(output))
|
|
@@ -74,6 +74,14 @@ module HarnessEvaluation
|
|
|
74
74
|
status = nil
|
|
75
75
|
capped = false
|
|
76
76
|
ready_observed = false
|
|
77
|
+
phase = ready_path ? 'startup' : 'execution'
|
|
78
|
+
timeout_phase = nil
|
|
79
|
+
# A restart reuses the workspace, not the previous process's readiness.
|
|
80
|
+
FileUtils.rm_f(ready_path) if ready_path
|
|
81
|
+
# Source-tree fixtures need the scratch-built extension. Packaged fixtures
|
|
82
|
+
# use their isolated GEM_HOME and must load their own installed extension.
|
|
83
|
+
env = env.merge('TMPDIR' => ENV['TMPDIR']) if ENV['TMPDIR']
|
|
84
|
+
env = env.merge('RUBYLIB' => ENV['RUBYLIB']) if env['GEM_HOME'] == Gem.dir && ENV['RUBYLIB']
|
|
77
85
|
Open3.popen3(env, *command, chdir: cwd, unsetenv_others: true, pgroup: true) do |input, output, error, waiter|
|
|
78
86
|
input.close
|
|
79
87
|
output.set_encoding(Encoding::UTF_8)
|
|
@@ -86,12 +94,14 @@ module HarnessEvaluation
|
|
|
86
94
|
end
|
|
87
95
|
ready_observed = File.file?(ready_path)
|
|
88
96
|
end
|
|
97
|
+
phase = 'execution' if !ready_path || ready_observed
|
|
89
98
|
Timeout.timeout(seconds) do
|
|
90
99
|
status = waiter.value
|
|
91
100
|
stdout, stderr = readers.map(&:value)
|
|
92
101
|
end
|
|
93
102
|
rescue Timeout::Error
|
|
94
103
|
capped = true
|
|
104
|
+
timeout_phase = phase
|
|
95
105
|
begin
|
|
96
106
|
Process.kill('KILL', -waiter.pid)
|
|
97
107
|
rescue Errno::ESRCH
|
|
@@ -103,6 +113,7 @@ module HarnessEvaluation
|
|
|
103
113
|
end
|
|
104
114
|
{ stdout: stdout, stderr: stderr, exit_status: status.exitstatus,
|
|
105
115
|
signal: status.termsig, resource_cap: capped, ready_observed: ready_observed,
|
|
116
|
+
timeout_phase: timeout_phase, startup_bound_seconds: ready_path ? startup_seconds : nil, execution_bound_seconds: seconds,
|
|
106
117
|
latency_seconds: Process.clock_gettime(Process::CLOCK_MONOTONIC) - started }
|
|
107
118
|
end
|
|
108
119
|
|
|
@@ -130,7 +141,7 @@ module HarnessEvaluation
|
|
|
130
141
|
ruby = RbConfig.ruby
|
|
131
142
|
rows = manifest.fetch('tasks').map do |task|
|
|
132
143
|
root = Dir.mktmpdir("#{task.fetch('id')}-", output_root)
|
|
133
|
-
home =
|
|
144
|
+
home = "#{root}-home"
|
|
134
145
|
FileUtils.mkdir_p(home)
|
|
135
146
|
fixture = File.join(root, 'provider.json')
|
|
136
147
|
File.write(fixture, JSON.generate(responses: task.fetch('responses')))
|
|
@@ -28,7 +28,7 @@ module HarnessExperiments
|
|
|
28
28
|
|
|
29
29
|
def self.trial(task, arm, gold, snapshot, options)
|
|
30
30
|
root = Dir.mktmpdir("#{task.fetch('id')}-#{arm}-", options.fetch(:output))
|
|
31
|
-
home =
|
|
31
|
+
home = "#{root}-home"
|
|
32
32
|
FileUtils.mkdir_p(home)
|
|
33
33
|
FileUtils.cp_r(File.join(snapshot, '.'), home) if snapshot && arm != 'single-cold'
|
|
34
34
|
# The immutable source snapshot is never mounted as a writable live store.
|