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
|
@@ -5,17 +5,76 @@ require 'tmpdir'
|
|
|
5
5
|
require 'fileutils'
|
|
6
6
|
|
|
7
7
|
describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
|
|
8
|
+
around do |example|
|
|
9
|
+
Dir.mktmpdir('loop-controller-') do |control|
|
|
10
|
+
@runtime_control = control
|
|
11
|
+
example.run
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
before do
|
|
16
|
+
FileUtils.mkdir_p(File.join(@runtime_control, 'sessions'))
|
|
17
|
+
allow(PWN::Sessions).to receive(:sessions_dir).and_return(File.join(@runtime_control, 'sessions'))
|
|
18
|
+
end
|
|
19
|
+
|
|
8
20
|
describe 'runtime execution boundaries' do
|
|
9
21
|
include_context 'pwn tmp sandbox'
|
|
10
22
|
|
|
11
23
|
before do
|
|
12
24
|
allow(Dir).to receive(:home).and_return(@tmp)
|
|
25
|
+
@work = File.join(@tmp, 'work')
|
|
26
|
+
FileUtils.mkdir_p(@work)
|
|
13
27
|
stub_const('PWN::AI::Agent::OpenGoal::GOAL_FILE', File.join(@tmp, 'open_goal.json'))
|
|
14
28
|
allow(PWN::AI::Agent::TaskSummarizer).to receive(:enabled?).and_return(false)
|
|
15
29
|
allow(described_class).to receive(:should_auto_introspect?).and_return(false)
|
|
16
30
|
@agent_cfg[:plan_first] = false
|
|
17
31
|
end
|
|
18
32
|
|
|
33
|
+
it 'finishes the offline PDF flow after cumulative combined and separate host observations' do
|
|
34
|
+
FileUtils.mkdir_p(File.join(@tmp, 'output'))
|
|
35
|
+
pdf = File.join(@tmp, 'output', 'container_pentest-PWN.pdf')
|
|
36
|
+
note = File.join(@tmp, 'output', 'notes.txt')
|
|
37
|
+
request = "Generate a report, i.e. a PDF; save #{pdf} and #{note}"
|
|
38
|
+
runtime = PWN::AI::Agent::RequestRuntime.new(request: request, session_id: 'pdf-replay', engine: :openai, artifact_root: @runtime_control, trace: false)
|
|
39
|
+
code = "PWN::Reports::PDF.generate(path: #{pdf.inspect}, title: 'Offline pentest fixture', findings: []); File.read(#{pdf.inspect}, 8)"
|
|
40
|
+
turns = [
|
|
41
|
+
{ role: 'assistant', tool_calls: [{ id: 'pdf-write', type: 'function', function: { name: 'pwn_eval', arguments: { code: code }.to_json } }] },
|
|
42
|
+
{ role: 'assistant', tool_calls: [{ id: 'note-write', type: 'function', function: { name: 'shell', arguments: { command: "printf verified > #{note}", timeout: 10 }.to_json } }] },
|
|
43
|
+
{ role: 'assistant', tool_calls: [{ id: 'readback', type: 'function', function: { name: 'shell', arguments: { command: "stat #{pdf}; sha256sum #{pdf}; cat #{note}", timeout: 10 }.to_json } }] },
|
|
44
|
+
{ role: 'assistant', content: 'The PDF and notes are written and verified.' }
|
|
45
|
+
]
|
|
46
|
+
hops = 0
|
|
47
|
+
allow(PWN::AI::OpenAI).to receive(:chat_with_tools) do
|
|
48
|
+
hops += 1
|
|
49
|
+
raise 'unexpected completion bounce' if turns.empty?
|
|
50
|
+
|
|
51
|
+
{ choices: [{ message: turns.shift }] }
|
|
52
|
+
end
|
|
53
|
+
tool_results = []
|
|
54
|
+
result = described_class.run(request: request, session_id: 'pdf-replay', runtime: runtime, system_role_content: 'offline fixture', on_tool: ->(*row) { tool_results << row })
|
|
55
|
+
expect(result).to eq('The PDF and notes are written and verified.'), tool_results.inspect
|
|
56
|
+
expect(hops).to eq(4)
|
|
57
|
+
expect(runtime.contract[:required_artifacts]).to contain_exactly(pdf, note)
|
|
58
|
+
expect(runtime.outcome[:status]).to eq('complete')
|
|
59
|
+
expect(runtime.observations.keys).to contain_exactly(pdf, note)
|
|
60
|
+
expect(File.binread(pdf)).to start_with('%PDF-1.4')
|
|
61
|
+
expect(File.binread(pdf)).to include('%%EOF')
|
|
62
|
+
expect(runtime.observations[pdf][:sha256]).to eq(Digest::SHA256.file(pdf).hexdigest)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it 'returns a precise blocked outcome instead of bouncing uncertain effects forever' do
|
|
66
|
+
runtime = PWN::AI::Agent::RequestRuntime.new(request: 'Perform the fixture mutation', session_id: 'uncertain-replay', engine: :openai, artifact_root: @runtime_control, trace: false)
|
|
67
|
+
expect do
|
|
68
|
+
runtime.execute_tool(name: 'fixture_write', args: { target: 'exact-target' }, effect: :write, action_id: 'uncertain-call') { raise Timeout::Error }
|
|
69
|
+
end.to raise_error(Timeout::Error)
|
|
70
|
+
allow(PWN::AI::OpenAI).to receive(:chat_with_tools).and_return(choices: [{ message: { role: 'assistant', content: 'Done.' } }])
|
|
71
|
+
result = described_class.run(request: runtime.request, session_id: runtime.session_id, runtime: runtime, system_role_content: 'offline fixture')
|
|
72
|
+
expect(result).to include('blocked:', 'uncertain-call', 'operator-approved reconciliation')
|
|
73
|
+
expect(runtime.outcome[:status]).to eq('blocked')
|
|
74
|
+
expect(runtime.unmet).to eq(['uncertain_side_effect'])
|
|
75
|
+
expect(PWN::AI::OpenAI).to have_received(:chat_with_tools).once
|
|
76
|
+
end
|
|
77
|
+
|
|
19
78
|
it 'verifies an exact HTTP target at the actual Loop finalization boundary' do
|
|
20
79
|
require 'socket'
|
|
21
80
|
server = TCPServer.new('127.0.0.1', 0)
|
|
@@ -30,7 +89,7 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
|
|
|
30
89
|
client.close
|
|
31
90
|
end
|
|
32
91
|
sid = PWN::Sessions.create(title: 'http verifier fixture')[:id]
|
|
33
|
-
runtime = PWN::AI::Agent::RequestRuntime.new(request: request, session_id: sid, engine: :openai, artifact_root: @
|
|
92
|
+
runtime = PWN::AI::Agent::RequestRuntime.new(request: request, session_id: sid, engine: :openai, artifact_root: @runtime_control, trace: false)
|
|
34
93
|
allow(PWN::AI::OpenAI).to receive(:chat_with_tools).and_return(choices: [{ message: { role: 'assistant', content: 'Checked.' } }])
|
|
35
94
|
contract = { root: @tmp, requirements: [request], allowed_urls: [url], checks: [{ requirement: request, kind: :http, url: url, expected: 'ok' }] }
|
|
36
95
|
result = described_class.run(request: request, session_id: sid, runtime: runtime, verification_contract: contract, enabled_toolsets: [], system_role_content: 'fixture')
|
|
@@ -113,16 +172,17 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
|
|
|
113
172
|
end
|
|
114
173
|
|
|
115
174
|
it 'cancels a real mutation in Loop and denies its replay after checkpoint restore' do
|
|
116
|
-
counter = File.join(@
|
|
175
|
+
counter = File.join(@work, 'cancel-counter.txt')
|
|
117
176
|
cancelled = false
|
|
118
|
-
options = { request:
|
|
119
|
-
artifact_root: @
|
|
177
|
+
options = { request: "Run the fixture mutation and write #{counter}", session_id: 'tool-cancel', request_id: 'same', engine: :openai,
|
|
178
|
+
artifact_root: @runtime_control, trace: false, cancel: -> { cancelled } }
|
|
120
179
|
runtime = PWN::AI::Agent::RequestRuntime.new(options)
|
|
121
180
|
args = { command: "printf 'once\\n' >> #{counter}; sleep 30", timeout: 180 }
|
|
122
181
|
reply = { role: 'assistant', tool_calls: [{ id: 'mutation', type: 'function', function: { name: 'shell', arguments: args.to_json } }] }
|
|
123
182
|
allow(PWN::AI::OpenAI).to receive(:chat_with_tools).and_return(choices: [{ message: reply }])
|
|
124
183
|
canceller = Thread.new do
|
|
125
|
-
|
|
184
|
+
# Wait for the actual mutation, including cold kernel-policy inventory.
|
|
185
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 60
|
|
126
186
|
sleep 0.01 until File.exist?(counter) || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
127
187
|
cancelled = true
|
|
128
188
|
end
|
|
@@ -163,18 +223,30 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
|
|
|
163
223
|
end
|
|
164
224
|
|
|
165
225
|
it 'overlaps independent reads while preserving real write barriers and start/end order' do
|
|
166
|
-
left = File.join(@
|
|
167
|
-
right = File.join(@
|
|
168
|
-
output = File.join(@
|
|
226
|
+
left = File.join(@work, 'left.txt')
|
|
227
|
+
right = File.join(@work, 'right.txt')
|
|
228
|
+
output = File.join(@work, 'out.txt')
|
|
169
229
|
File.write(left, 'left')
|
|
170
230
|
File.write(right, 'right')
|
|
171
|
-
|
|
172
|
-
lock = Mutex.new
|
|
231
|
+
timing_path = File.join(@work, 'timings.jsonl')
|
|
173
232
|
allow(PWN::Plugins::ArtifactRegistry).to receive(:read_page).and_wrap_original do |original, options|
|
|
174
233
|
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
175
|
-
|
|
234
|
+
rendezvous = "#{timing_path}.#{File.exist?(output) ? 'after' : 'before'}"
|
|
235
|
+
File.open(rendezvous, 'a') do |file|
|
|
236
|
+
file.flock(File::LOCK_EX)
|
|
237
|
+
file.puts('started')
|
|
238
|
+
end
|
|
239
|
+
deadline = start + 10
|
|
240
|
+
until File.readlines(rendezvous).length == 2
|
|
241
|
+
raise 'parallel reads did not rendezvous' if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
242
|
+
|
|
243
|
+
sleep 0.005
|
|
244
|
+
end
|
|
176
245
|
value = original.call(options)
|
|
177
|
-
|
|
246
|
+
File.open(timing_path, 'a') do |file|
|
|
247
|
+
file.flock(File::LOCK_EX)
|
|
248
|
+
file.puts(JSON.generate([start, Process.clock_gettime(Process::CLOCK_MONOTONIC)]))
|
|
249
|
+
end
|
|
178
250
|
value
|
|
179
251
|
end
|
|
180
252
|
calls = [left, right].each_with_index.map { |path, index| { id: "read#{index}", type: 'function', function: { name: 'artifact_read', arguments: { path: path }.to_json } } }
|
|
@@ -183,8 +255,9 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
|
|
|
183
255
|
calls.concat([left, right].each_with_index.map { |path, index| { id: "after#{index}", type: 'function', function: { name: 'artifact_read', arguments: { path: path }.to_json } } })
|
|
184
256
|
replies = [{ role: 'assistant', tool_calls: calls }, { role: 'assistant', content: 'Verified.' }]
|
|
185
257
|
allow(PWN::AI::OpenAI).to receive(:chat_with_tools) { { choices: [{ message: replies.shift || raise('unexpected provider call') }] } }
|
|
186
|
-
runtime = PWN::AI::Agent::RequestRuntime.new(request: "Write #{output}", session_id: 'parallel', engine: :openai, trace: false, artifact_root: @
|
|
258
|
+
runtime = PWN::AI::Agent::RequestRuntime.new(request: "Write #{output}", session_id: 'parallel', engine: :openai, trace: false, artifact_root: @runtime_control)
|
|
187
259
|
expect(described_class.run(request: runtime.request, runtime: runtime, session_id: 'parallel', system_role_content: 'fixture')).to eq('Verified.')
|
|
260
|
+
timings = File.readlines(timing_path).map { |line| JSON.parse(line) }
|
|
188
261
|
expect(timings.length).to eq(4)
|
|
189
262
|
expect(timings[0][0]).to be < timings[1][1]
|
|
190
263
|
expect(timings[1][0]).to be < timings[0][1]
|
|
@@ -205,6 +278,8 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
|
|
|
205
278
|
|
|
206
279
|
before do
|
|
207
280
|
allow(Dir).to receive(:home).and_return(@tmp)
|
|
281
|
+
@work = File.join(@tmp, 'work')
|
|
282
|
+
FileUtils.mkdir_p(@work)
|
|
208
283
|
stub_const('PWN::AI::Agent::OpenGoal::GOAL_FILE', File.join(@tmp, 'open_goal.json'))
|
|
209
284
|
allow(PWN::AI::Agent::TaskSummarizer).to receive(:enabled?).and_return(false)
|
|
210
285
|
allow(described_class).to receive(:should_auto_introspect?).and_return(false)
|
|
@@ -212,7 +287,7 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
|
|
|
212
287
|
end
|
|
213
288
|
|
|
214
289
|
it 'accepts Verification complete after an actual write without wording-dependent retries' do
|
|
215
|
-
path = File.join(@
|
|
290
|
+
path = File.join(@work, 'answer.txt')
|
|
216
291
|
replies = [
|
|
217
292
|
{ role: 'assistant', tool_calls: [{ id: 'write', type: 'function', function: { name: 'shell', arguments: { command: "printf verified > #{path}" }.to_json } }] },
|
|
218
293
|
{ role: 'assistant', content: 'Verification complete.' }
|
|
@@ -240,7 +315,7 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
|
|
|
240
315
|
end
|
|
241
316
|
|
|
242
317
|
it 'automatically restores the session checkpoint identity for continue' do
|
|
243
|
-
runtime = PWN::AI::Agent::RequestRuntime.new(request: "Write #{@tmp}/resume.txt", session_id: 'resume', request_id: 'original', artifact_root: @
|
|
318
|
+
runtime = PWN::AI::Agent::RequestRuntime.new(request: "Write #{@tmp}/resume.txt", session_id: 'resume', request_id: 'original', artifact_root: @runtime_control, trace: false)
|
|
244
319
|
Thread.current[:pwn_request_runtime] = runtime
|
|
245
320
|
PWN::AI::Agent::OpenGoal.begin!(request: runtime.request, session_id: 'resume')
|
|
246
321
|
runtime.finish!(status: 'incomplete')
|
|
@@ -259,7 +334,7 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
|
|
|
259
334
|
|
|
260
335
|
it 'drives the actual runtime/provider adapter boundary and harmless Dispatch write' do
|
|
261
336
|
PWN::Env[:ai][:active] = :openai
|
|
262
|
-
path = File.join(@
|
|
337
|
+
path = File.join(@work, 'provider.txt')
|
|
263
338
|
replies = [
|
|
264
339
|
{ role: 'assistant', tool_calls: [{ id: 'write', type: 'function', function: { name: 'shell', arguments: { command: "printf provider > #{path}" }.to_json } }] },
|
|
265
340
|
{ role: 'assistant', content: 'Verification complete.' }
|
|
@@ -274,8 +349,8 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
|
|
|
274
349
|
end
|
|
275
350
|
|
|
276
351
|
it 'retains completion evidence after all tool messages are evicted' do
|
|
277
|
-
path = File.join(@
|
|
278
|
-
runtime = PWN::AI::Agent::RequestRuntime.new(request: "Write #{path}", session_id: 'compact', artifact_root: @
|
|
352
|
+
path = File.join(@work, 'compacted.txt')
|
|
353
|
+
runtime = PWN::AI::Agent::RequestRuntime.new(request: "Write #{path}", session_id: 'compact', artifact_root: @runtime_control, trace: false)
|
|
279
354
|
replies = [
|
|
280
355
|
{ role: 'assistant', tool_calls: [{ id: 'write', type: 'function', function: { name: 'shell', arguments: { command: "printf compacted > #{path}" }.to_json } }] },
|
|
281
356
|
{ role: 'assistant', content: 'Verification complete.' }
|
|
@@ -354,7 +429,7 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
|
|
|
354
429
|
|
|
355
430
|
it 'terminates checker errors with a durable verification_error outcome, not a success' do
|
|
356
431
|
request = "Write #{File.join(@tmp, 'error.txt')}"
|
|
357
|
-
runtime = PWN::AI::Agent::RequestRuntime.new(request: request, session_id: 'error', artifact_root: @
|
|
432
|
+
runtime = PWN::AI::Agent::RequestRuntime.new(request: request, session_id: 'error', artifact_root: @runtime_control, trace: false)
|
|
358
433
|
allow(PWN::AI::Agent::TurnFinalizer).to receive(:arbitrate).and_raise(IOError, 'checker unavailable')
|
|
359
434
|
allow(described_class).to receive(:call_engine).and_return(role: 'assistant', content: 'Done.')
|
|
360
435
|
expect(described_class).to receive(:call_engine).once
|
|
@@ -363,10 +438,10 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
|
|
|
363
438
|
end
|
|
364
439
|
|
|
365
440
|
it 'rejects a failed explicit content check before publishing a successful final' do
|
|
366
|
-
path = File.join(@
|
|
441
|
+
path = File.join(@work, 'checked.txt')
|
|
367
442
|
request = "Write #{path} containing correct"
|
|
368
443
|
sid = PWN::Sessions.create(title: 'verification fixture')[:id]
|
|
369
|
-
runtime = PWN::AI::Agent::RequestRuntime.new(request: request, session_id: sid, artifact_root: @
|
|
444
|
+
runtime = PWN::AI::Agent::RequestRuntime.new(request: request, session_id: sid, artifact_root: @runtime_control, trace: false)
|
|
370
445
|
replies = [
|
|
371
446
|
{ role: 'assistant', tool_calls: [{ id: 'write', type: 'function', function: { name: 'shell', arguments: { command: "printf wrong > #{path}" }.to_json } }] },
|
|
372
447
|
{ role: 'assistant', content: 'Done.' }
|
|
@@ -379,13 +454,13 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
|
|
|
379
454
|
end
|
|
380
455
|
|
|
381
456
|
it 'keeps completed evidence and original destinations through additive steering at the real entry' do
|
|
382
|
-
first = File.join(@
|
|
383
|
-
second = File.join(@
|
|
457
|
+
first = File.join(@work, 'first.txt')
|
|
458
|
+
second = File.join(@work, 'second.txt')
|
|
384
459
|
input, writer = IO.pipe
|
|
385
460
|
control = described_class::Steering.new(input: input, output: StringIO.new)
|
|
386
461
|
request = "Write #{first}"
|
|
387
462
|
sid = PWN::Sessions.create(title: 'amendment verification')[:id]
|
|
388
|
-
runtime = PWN::AI::Agent::RequestRuntime.new(request: request, session_id: sid, artifact_root: @
|
|
463
|
+
runtime = PWN::AI::Agent::RequestRuntime.new(request: request, session_id: sid, artifact_root: @runtime_control, trace: false)
|
|
389
464
|
count = 0
|
|
390
465
|
allow(described_class).to receive(:call_engine) do
|
|
391
466
|
count += 1
|
|
@@ -418,7 +493,7 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
|
|
|
418
493
|
it 'persists cancellation and restores the runtime binding without a provider restart' do
|
|
419
494
|
input, writer = IO.pipe
|
|
420
495
|
control = described_class::Steering.new(input: input, output: StringIO.new)
|
|
421
|
-
runtime = PWN::AI::Agent::RequestRuntime.new(request: 'What color is a lemon?', session_id: 'cancel', artifact_root: @
|
|
496
|
+
runtime = PWN::AI::Agent::RequestRuntime.new(request: 'What color is a lemon?', session_id: 'cancel', artifact_root: @runtime_control, trace: false)
|
|
422
497
|
control.submit('cancel')
|
|
423
498
|
expect(described_class).not_to receive(:call_engine)
|
|
424
499
|
result = described_class.run(request: runtime.request, runtime: runtime, session_id: 'cancel', steering: control,
|
|
@@ -451,7 +526,7 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
|
|
|
451
526
|
|
|
452
527
|
it 'does not reuse a previous terminal success when the resumed provider returns nothing' do
|
|
453
528
|
request = 'What color is a lemon?'
|
|
454
|
-
runtime = PWN::AI::Agent::RequestRuntime.new(request: request, session_id: 'resume', artifact_root: @
|
|
529
|
+
runtime = PWN::AI::Agent::RequestRuntime.new(request: request, session_id: 'resume', artifact_root: @runtime_control, trace: false)
|
|
455
530
|
runtime.finish!(status: 'complete')
|
|
456
531
|
allow(described_class).to receive(:call_engine).and_return(nil)
|
|
457
532
|
described_class.run(request: request, runtime: runtime, session_id: 'resume', enabled_toolsets: [], system_role_content: 'fixture')
|
|
@@ -516,7 +591,7 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
|
|
|
516
591
|
described_class.send(:wait_trace_step!, label: 'engine')
|
|
517
592
|
'done'
|
|
518
593
|
end
|
|
519
|
-
expect(PWN::Plugins::REPL.
|
|
594
|
+
expect(PWN::Plugins::REPL.pwn_ai_run_steerable_child(input: input, output: StringIO.new, request: 'test')).to eq('done')
|
|
520
595
|
expect(Thread.current[:pwn_steering_input]).to be_nil
|
|
521
596
|
ensure
|
|
522
597
|
input&.close
|
|
@@ -659,7 +734,7 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
|
|
|
659
734
|
queued.pop
|
|
660
735
|
writer.puts('tool answer')
|
|
661
736
|
end
|
|
662
|
-
result = PWN::Plugins::REPL.
|
|
737
|
+
result = PWN::Plugins::REPL.pwn_ai_run_steerable_child(
|
|
663
738
|
input: input, output: output, request: old_request,
|
|
664
739
|
session_id: sid, system_role_content: 'test system', enabled_toolsets: [], on_tool: on_tool
|
|
665
740
|
)
|
|
@@ -701,7 +776,7 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
|
|
|
701
776
|
entered.pop
|
|
702
777
|
writer.puts('/steer what color is a lemon?')
|
|
703
778
|
end
|
|
704
|
-
result = PWN::Plugins::REPL.
|
|
779
|
+
result = PWN::Plugins::REPL.pwn_ai_run_steerable_child(
|
|
705
780
|
input: input, output: output, request: 'what color is a passion fruit?',
|
|
706
781
|
session_id: sid, system_role_content: 'test system', enabled_toolsets: []
|
|
707
782
|
)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
|
|
6
|
+
RSpec.describe 'PWN::AI::Agent::MissionNamespace compiler-free process ownership' do
|
|
7
|
+
around do |example|
|
|
8
|
+
previous = PWN::AI::Agent::MissionNamespace.force_exec
|
|
9
|
+
PWN::AI::Agent::MissionNamespace.force_exec = true
|
|
10
|
+
example.run
|
|
11
|
+
ensure
|
|
12
|
+
PWN::AI::Agent::MissionNamespace.force_exec = previous
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it 'loads through root and direct requires without suppressing sibling autoloads' do
|
|
16
|
+
%w[pwn pwn/ai/agent/mission_namespace].each do |entry|
|
|
17
|
+
code = <<~RUBY
|
|
18
|
+
require ARGV.fetch(0)
|
|
19
|
+
owner = PWN::AI::Agent::MissionNamespace
|
|
20
|
+
abort 'wrong owner' unless owner.name == 'PWN::AI::Agent::MissionNamespace'
|
|
21
|
+
abort 'root alias' if PWN.const_defined?(:MissionNamespace, false)
|
|
22
|
+
abort 'missing context' unless PWN::AI::Context.respond_to?(:help)
|
|
23
|
+
abort 'missing runtime' unless PWN::AI::Agent::RequestRuntime.respond_to?(:help)
|
|
24
|
+
puts owner.name
|
|
25
|
+
RUBY
|
|
26
|
+
stdout, stderr, status = Open3.capture3(RbConfig.ruby, '-I', File.expand_path('../../../../../lib', __dir__), '-e', code, entry)
|
|
27
|
+
expect(status.success?).to be(true), stderr
|
|
28
|
+
expect(stdout.strip).to eq('PWN::AI::Agent::MissionNamespace')
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'preserves permitted YAML dates, timestamps, symbols and binary strings in snapshots' do
|
|
33
|
+
value = { date: Date.new(2026, 10, 29), time: Time.at(123, 456, :nanosecond).getlocal('+09:00'), bytes: "\xFF".b, route: :openai }
|
|
34
|
+
restored = PWN::AI::Agent::MissionNamespace.decode(PWN::AI::Agent::MissionNamespace.encode(value))
|
|
35
|
+
expect(restored).to eq(value)
|
|
36
|
+
expect(restored[:time].utc_offset).to eq(value[:time].utc_offset)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'preserves the exit status of a failed mission' do
|
|
40
|
+
mission = PWN::AI::Agent::MissionNamespace.new(descriptor: { operation: :command, argv: [RbConfig.ruby, '-e', 'exit! 73'] })
|
|
41
|
+
mission.wait
|
|
42
|
+
expect(mission.status.exitstatus).to eq(73)
|
|
43
|
+
ensure
|
|
44
|
+
mission&.close
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it 'terminates owned descendants when the controller dies' do
|
|
48
|
+
Dir.mktmpdir('mission-owner-') do |root|
|
|
49
|
+
ready = File.join(root, 'ready')
|
|
50
|
+
late = File.join(root, 'late')
|
|
51
|
+
owner = fork do
|
|
52
|
+
code = "Process.setsid; File.write(ARGV[0], 'ready'); sleep 1; File.write(ARGV[1], 'late')"
|
|
53
|
+
mission = PWN::AI::Agent::MissionNamespace.new(descriptor: { operation: :command, argv: [RbConfig.ruby, '-e', code, ready, late] })
|
|
54
|
+
sleep 30
|
|
55
|
+
ensure
|
|
56
|
+
mission&.close
|
|
57
|
+
exit!
|
|
58
|
+
end
|
|
59
|
+
Timeout.timeout(10) { sleep 0.01 until File.exist?(ready) }
|
|
60
|
+
Process.kill('KILL', owner)
|
|
61
|
+
Process.wait(owner)
|
|
62
|
+
owner = nil
|
|
63
|
+
sleep 1.2
|
|
64
|
+
expect(File.exist?(late)).to be(false)
|
|
65
|
+
ensure
|
|
66
|
+
if owner
|
|
67
|
+
Process.kill('KILL', owner)
|
|
68
|
+
Process.wait(owner)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it 'rejects cyclic snapshots before attempting a launch' do
|
|
74
|
+
value = {}
|
|
75
|
+
value[:self] = value
|
|
76
|
+
expect { PWN::AI::Agent::MissionNamespace.encode(value) }.to raise_error(ArgumentError, /snapshot.*deep/)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it 'allows cancellation while another thread waits for completion' do
|
|
80
|
+
mission = PWN::AI::Agent::MissionNamespace.new(descriptor: { operation: :command, argv: ['/bin/sleep', '30'] })
|
|
81
|
+
waiter = Thread.new { mission.wait }
|
|
82
|
+
sleep 0.05
|
|
83
|
+
Timeout.timeout(5) do
|
|
84
|
+
mission.close
|
|
85
|
+
waiter.join
|
|
86
|
+
end
|
|
87
|
+
expect(waiter.value).to eq(:reaped)
|
|
88
|
+
ensure
|
|
89
|
+
mission&.close
|
|
90
|
+
waiter&.kill&.join
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it 'enforces the filesystem boundary through a fresh descriptor worker' do
|
|
94
|
+
Dir.mktmpdir('mission-source-') do |root|
|
|
95
|
+
source = File.join(root, 'source')
|
|
96
|
+
work = File.join(root, 'work')
|
|
97
|
+
FileUtils.mkdir_p([source, work])
|
|
98
|
+
File.write(File.join(source, 'keep'), 'original')
|
|
99
|
+
result = PWN::Plugins::AISandbox.protect_call(roots: [source], workspace: work, write_roots: [work],
|
|
100
|
+
handler: { name: 'shell', args: { command: "printf allowed > result; printf bad > #{source}/keep", timeout: 5 } })
|
|
101
|
+
expect(result[:stderr]).to include('Permission denied')
|
|
102
|
+
expect(File.read(File.join(source, 'keep'))).to eq('original')
|
|
103
|
+
expect(File.read(File.join(work, 'result'))).to eq('allowed')
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it 'executes a console slash descriptor after a fresh exec' do
|
|
108
|
+
reader, writer = IO.pipe
|
|
109
|
+
input, sender = IO.pipe
|
|
110
|
+
mission = PWN::AI::Agent::MissionNamespace.new(descriptor: { operation: :slash, request: '/help', generation: 'test', config: {}, baseline_ai: JSON.generate(PWN::Env[:ai]) }, fds: { 4 => writer, 5 => input })
|
|
111
|
+
writer.close
|
|
112
|
+
rows = Timeout.timeout(10) { reader.readlines.map { |line| JSON.parse(line) } }
|
|
113
|
+
mission.wait
|
|
114
|
+
expect(rows.map { |row| row[1] }).to include('command_state')
|
|
115
|
+
expect(rows.none? { |row| row[1] == 'warning' }).to be(true)
|
|
116
|
+
ensure
|
|
117
|
+
mission&.close
|
|
118
|
+
[reader, writer, input, sender].compact.each { |io| io.close unless io.closed? }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it 'executes a descriptor and reaps detached descendants without an extension' do
|
|
122
|
+
Dir.mktmpdir('mission-ruby-') do |root|
|
|
123
|
+
ready = File.join(root, 'ready')
|
|
124
|
+
late = File.join(root, 'late')
|
|
125
|
+
code = "fork { Process.setsid; File.write(ARGV[0], 'ready'); sleep 1; File.write(ARGV[1], 'late') }; sleep 30"
|
|
126
|
+
mission = PWN::AI::Agent::MissionNamespace.new(descriptor: { operation: :command, argv: [RbConfig.ruby, '-e', code, ready, late] })
|
|
127
|
+
Timeout.timeout(10) { sleep 0.01 until File.exist?(ready) }
|
|
128
|
+
mission.close
|
|
129
|
+
sleep 1.1
|
|
130
|
+
expect(File.exist?(late)).to be(false)
|
|
131
|
+
next_mission = PWN::AI::Agent::MissionNamespace.new(descriptor: { operation: :command, argv: ['/bin/true'] })
|
|
132
|
+
expect(next_mission.wait).to eq(:reaped)
|
|
133
|
+
ensure
|
|
134
|
+
mission&.close
|
|
135
|
+
next_mission&.close
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -12,6 +12,33 @@ describe PWN::AI::Agent::Policy do
|
|
|
12
12
|
expect(described_class).to respond_to :help
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
+
it 'serializes trajectory retention and episode append on the same permanent writer lock' do
|
|
16
|
+
Dir.mktmpdir do |tmp|
|
|
17
|
+
stub_const('PWN::AI::Agent::Policy::POLICY_FILE', File.join(tmp, 'policy.json'))
|
|
18
|
+
stub_const('PWN::AI::Agent::Policy::TRAJECTORY_FILE', File.join(tmp, 'policy_traj.jsonl'))
|
|
19
|
+
File.write(described_class::TRAJECTORY_FILE, '')
|
|
20
|
+
[-> { described_class.lean! }, -> { described_class.send(:persist_episode!, episode: { session_id: 'fixture', score: nil, steps: [] }) }].each do |operation|
|
|
21
|
+
entered = Queue.new
|
|
22
|
+
thread = nil
|
|
23
|
+
begin
|
|
24
|
+
PWN::Memory.transaction(path: described_class::TRAJECTORY_FILE) do
|
|
25
|
+
thread = Thread.new do
|
|
26
|
+
entered << true
|
|
27
|
+
operation.call
|
|
28
|
+
end
|
|
29
|
+
entered.pop
|
|
30
|
+
expect(thread.join(0.1)).to be_nil
|
|
31
|
+
end
|
|
32
|
+
expect(thread.join(2)).to eq(thread)
|
|
33
|
+
thread.value
|
|
34
|
+
ensure
|
|
35
|
+
thread&.join(2)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
expect(described_class.trajectories.length).to eq(1)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
15
42
|
it 'learns a better Q for a rewarded action than a punished one' do
|
|
16
43
|
tmp = Dir.mktmpdir
|
|
17
44
|
stub_const('PWN::AI::Agent::Policy::POLICY_FILE', File.join(tmp, 'policy.json'))
|
|
@@ -6,6 +6,54 @@ require 'pwn/ai/agent/request_runtime'
|
|
|
6
6
|
describe PWN::AI::Agent::RequestRuntime do
|
|
7
7
|
include_context 'pwn tmp sandbox'
|
|
8
8
|
|
|
9
|
+
it 'runs arbitrary runtime dispatch handlers behind the mandatory source boundary' do
|
|
10
|
+
registry = PWN::AI::Agent::Registry
|
|
11
|
+
entries = registry.instance_variable_get(:@entries).dup
|
|
12
|
+
discovered = registry.instance_variable_get(:@discovered)
|
|
13
|
+
protected = File.join(@tmp, 'checkout')
|
|
14
|
+
FileUtils.mkdir_p(protected)
|
|
15
|
+
target = File.join(protected, 'keep')
|
|
16
|
+
File.write(target, 'original')
|
|
17
|
+
allow(PWN::Plugins::AISandbox).to receive(:protected_roots).and_return([protected])
|
|
18
|
+
runtime = described_class.new(request: 'Write a report', session_id: 'protected', artifact_root: @tmp, trace: false)
|
|
19
|
+
Thread.current[:pwn_request_runtime] = runtime
|
|
20
|
+
PWN::AI::Agent::Registry.register(name: 'boundary_fixture', toolset: 'test', schema: { name: 'boundary_fixture', parameters: { type: 'object' } }, handler: ->(_args) { File.write(target, 'bad') })
|
|
21
|
+
response = JSON.parse(PWN::AI::Agent::Dispatch.call(tool_call: { id: 'protected', function: { name: 'boundary_fixture', arguments: '{}' } }))
|
|
22
|
+
expect(File.read(target)).to eq('original')
|
|
23
|
+
expect(response['success']).to be false
|
|
24
|
+
ensure
|
|
25
|
+
registry.instance_variable_set(:@entries, entries)
|
|
26
|
+
registry.instance_variable_set(:@discovered, discovered)
|
|
27
|
+
Thread.current[:pwn_request_runtime] = nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'accumulates host artifact receipts from successful eval writes without transcript claims' do
|
|
31
|
+
first = File.join(@tmp, 'first.pdf')
|
|
32
|
+
second = File.join(@tmp, 'second.txt')
|
|
33
|
+
runtime = described_class.new(request: "Write #{first} and #{second}", session_id: 'cumulative', artifact_root: @tmp, trace: false)
|
|
34
|
+
Thread.current[:pwn_request_runtime] = runtime
|
|
35
|
+
Thread.current[:pwn_artifact_contract] = runtime.contract
|
|
36
|
+
runtime.execute_tool(name: 'fixture', args: { file: first }, effect: :eval, action_id: 'one') do
|
|
37
|
+
File.write(first, '%PDF-1.4 fixture')
|
|
38
|
+
{ value: 'written' }
|
|
39
|
+
end
|
|
40
|
+
runtime.execute_tool(name: 'fixture', args: { file: second }, effect: :eval, action_id: 'two') do
|
|
41
|
+
File.write(second, 'verified')
|
|
42
|
+
{ value: 'written' }
|
|
43
|
+
end
|
|
44
|
+
runtime.execute_tool(name: 'fixture', args: { file: second }, effect: :read, action_id: 'three') do
|
|
45
|
+
File.read(second)
|
|
46
|
+
{ value: 'read' }
|
|
47
|
+
end
|
|
48
|
+
expect(runtime.observations.keys).to contain_exactly(first, second)
|
|
49
|
+
expect(PWN::AI::Agent::TurnFinalizer.arbitrate(request: runtime.request)[:complete]).to be true
|
|
50
|
+
File.write(first, 'stale')
|
|
51
|
+
expect(PWN::AI::Agent::TurnFinalizer.arbitrate(request: runtime.request)[:complete]).to be false
|
|
52
|
+
ensure
|
|
53
|
+
Thread.current[:pwn_request_runtime] = nil
|
|
54
|
+
Thread.current[:pwn_artifact_contract] = nil
|
|
55
|
+
end
|
|
56
|
+
|
|
9
57
|
it 'accepts a binary-tagged request without raising JSON::GeneratorError' do
|
|
10
58
|
request = "status \u2022 ok".b
|
|
11
59
|
expect(request.encoding).to eq(Encoding::ASCII_8BIT)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'pwn/ai/agent/request_runtime'
|
|
5
|
+
|
|
6
|
+
describe 'finished shell searches do not block the answer' do
|
|
7
|
+
include_context 'pwn tmp sandbox'
|
|
8
|
+
|
|
9
|
+
it 'keeps a non-zero search exit as a failed observation' do
|
|
10
|
+
runtime = PWN::AI::Agent::RequestRuntime.new(request: 'how does pwn-ai use tools configured with openwebUi?', session_id: 'search-exit', artifact_root: @tmp, trace: false)
|
|
11
|
+
result = runtime.execute_tool(name: 'shell', args: { command: 'rg openwebui' }, effect: :exclusive, action_id: 'search') do
|
|
12
|
+
{ stdout: 'open_web_ui.rb', stderr: '', exit: 1 }
|
|
13
|
+
end
|
|
14
|
+
expect(result[:exit]).to eq(1)
|
|
15
|
+
expect(runtime.events.last[:state]).to eq('failed')
|
|
16
|
+
expect(runtime.blocker).to be_nil
|
|
17
|
+
expect(runtime.unmet).to be_empty
|
|
18
|
+
again = runtime.execute_tool(name: 'shell', args: { command: 'rg openwebui' }, effect: :exclusive, action_id: 'search-again') do
|
|
19
|
+
{ stdout: 'open_web_ui.rb', stderr: '', exit: 1 }
|
|
20
|
+
end
|
|
21
|
+
expect(again[:exit]).to eq(1)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'does not demand reconciliation for an interrupted non-write shell command' do
|
|
25
|
+
runtime = PWN::AI::Agent::RequestRuntime.new(request: 'how does pwn-ai use tools configured with openwebUi?', session_id: 'search-interrupt', artifact_root: @tmp, trace: false)
|
|
26
|
+
call = { name: 'shell', args: { command: 'rg openwebui' }, effect: :exclusive, action_id: 'search' }
|
|
27
|
+
expect { runtime.execute_tool(call) { raise Timeout::Error } }.to raise_error(Timeout::Error)
|
|
28
|
+
expect(runtime.blocker).to be_nil
|
|
29
|
+
expect(runtime.unmet).to be_empty
|
|
30
|
+
denied = runtime.execute_tool(call.merge(action_id: 'replay')) { raise 'replayed' }
|
|
31
|
+
expect(denied[:error]).to eq('uncertain_side_effect')
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'does not block the answer when a classified write returns permission denied' do
|
|
35
|
+
runtime = PWN::AI::Agent::RequestRuntime.new(request: 'how does pwn-ai use tools configured with openwebUi?', session_id: 'write-denied', artifact_root: @tmp, trace: false)
|
|
36
|
+
result = runtime.execute_tool(name: 'shell', args: { command: "python -c 'open(\"/tmp/server_tools.rb\",\"w\")'" }, effect: :write, action_id: 'denied-write') do
|
|
37
|
+
{ stdout: '', stderr: 'PermissionError: Permission denied: /tmp/server_tools.rb', exit: 1 }
|
|
38
|
+
end
|
|
39
|
+
expect(result[:exit]).to eq(1)
|
|
40
|
+
expect(runtime.events.last[:state]).to eq('failed')
|
|
41
|
+
expect(runtime.blocker).to be_nil
|
|
42
|
+
expect(runtime.unmet).to be_empty
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -50,7 +50,7 @@ RSpec.describe 'Swarm solve' do # rubocop:disable Metrics/BlockLength -- end-to-
|
|
|
50
50
|
PWN::Env[:ai][:active] = :openai
|
|
51
51
|
PWN::Env[:ai][:openai] = { key: 'offline-fixture-key' }
|
|
52
52
|
@agent_cfg[:plan_first] = false
|
|
53
|
-
runtime = PWN::AI::Agent::RequestRuntime.new(request: 'Enclosing request', session_id: 'enclosing', artifact_root: @tmp, trace: false)
|
|
53
|
+
runtime = PWN::AI::Agent::RequestRuntime.new(request: 'Enclosing request', session_id: 'enclosing', artifact_root: File.join(@tmp, 'controller'), trace: false)
|
|
54
54
|
expect(runtime).not_to receive(:call)
|
|
55
55
|
Thread.current[:pwn_request_runtime] = runtime
|
|
56
56
|
allow(PWN::AI::OpenAI).to receive(:chat_with_tools) do |opts|
|
|
@@ -78,7 +78,8 @@ RSpec.describe 'Swarm solve' do # rubocop:disable Metrics/BlockLength -- end-to-
|
|
|
78
78
|
console.instance_variable_set(:@mission_options, { workspace: @workspace, worktree: true, rounds: 1, verification_timeout: 60 })
|
|
79
79
|
out = Timeout.timeout(15) do
|
|
80
80
|
console.submit('/swarm mission Write hello to greeting.txt')
|
|
81
|
-
controller.send(:workers).last.
|
|
81
|
+
controller.send(:workers).last.join
|
|
82
|
+
controller.snapshot[:jobs].last[:team]
|
|
82
83
|
end
|
|
83
84
|
expect(out).to include(status: 'accepted')
|
|
84
85
|
expect(out[:evidence].first).to include(stdout: 'hello', exitstatus: 0)
|
|
@@ -95,6 +96,7 @@ RSpec.describe 'Swarm solve' do # rubocop:disable Metrics/BlockLength -- end-to-
|
|
|
95
96
|
expect(File.read(File.join(@workspace, 'greeting.txt'))).to eq('hello')
|
|
96
97
|
expect(JSON.parse(File.read(out[:record_path])).dig('application', 'status')).to eq('applied')
|
|
97
98
|
ensure
|
|
99
|
+
Thread.current[:pwn_request_runtime] = nil
|
|
98
100
|
console&.send(:stop_worker)
|
|
99
101
|
controller&.close
|
|
100
102
|
if out && out[:worktree]
|
|
@@ -620,8 +622,8 @@ RSpec.describe 'Swarm solve' do # rubocop:disable Metrics/BlockLength -- end-to-
|
|
|
620
622
|
end
|
|
621
623
|
|
|
622
624
|
it 'pauses dispatch, resumes, and cancels through the coordinator command entry' do
|
|
623
|
-
entered =
|
|
624
|
-
release =
|
|
625
|
+
entered = PWNTestPipeQueue.new
|
|
626
|
+
release = PWNTestPipeQueue.new
|
|
625
627
|
@hook = lambda do |handoff, _answer|
|
|
626
628
|
if handoff[:phase] == 'assess'
|
|
627
629
|
entered << true
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
describe 'PWN::AI::Agent::Tools browser_act' do
|
|
6
|
+
before(:all) do
|
|
7
|
+
PWN::AI::Agent::Registry.discover(force: true)
|
|
8
|
+
load '/opt/pwn/lib/pwn/ai/agent/tools/browser_act.rb'
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it 'registers browser_act' do
|
|
12
|
+
expect(PWN::AI::Agent::Registry.lookup(name: 'browser_act')).not_to be_nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it 'opens, acts, and closes when no browser is supplied' do
|
|
16
|
+
browser_obj = { type: :headless, browser: :stub }
|
|
17
|
+
allow(PWN::Plugins::TransparentBrowser).to receive(:open).with(browser_type: :headless).and_return(browser_obj)
|
|
18
|
+
allow(PWN::Plugins::TransparentBrowser).to receive(:act).and_return({ available: true, status: 'done' })
|
|
19
|
+
allow(PWN::Plugins::TransparentBrowser).to receive(:close)
|
|
20
|
+
|
|
21
|
+
result = PWN::AI::Agent::Registry.lookup(name: 'browser_act').handler.call(
|
|
22
|
+
'goal' => 'find the article',
|
|
23
|
+
'url' => 'https://example.test'
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
expect(result).to include(available: true, status: 'done')
|
|
27
|
+
expect(PWN::Plugins::TransparentBrowser).to have_received(:act).with(
|
|
28
|
+
hash_including(browser_obj: browser_obj, goal: 'find the article', url: 'https://example.test')
|
|
29
|
+
)
|
|
30
|
+
expect(PWN::Plugins::TransparentBrowser).to have_received(:close).with(browser_obj: browser_obj)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it 'does not close a browser the caller already owns' do
|
|
34
|
+
supplied = { type: :rest, browser: :kept }
|
|
35
|
+
allow(PWN::Plugins::TransparentBrowser).to receive(:open)
|
|
36
|
+
allow(PWN::Plugins::TransparentBrowser).to receive(:act).and_return({ available: false, status: 'unavailable' })
|
|
37
|
+
allow(PWN::Plugins::TransparentBrowser).to receive(:close)
|
|
38
|
+
|
|
39
|
+
PWN::AI::Agent::Registry.lookup(name: 'browser_act').handler.call(browser_obj: supplied, goal: 'look')
|
|
40
|
+
|
|
41
|
+
expect(PWN::Plugins::TransparentBrowser).not_to have_received(:open)
|
|
42
|
+
expect(PWN::Plugins::TransparentBrowser).not_to have_received(:close)
|
|
43
|
+
end
|
|
44
|
+
end
|