pwn 0.5.766 → 0.5.772

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.
Files changed (123) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +3 -3
  3. data/documentation/Configuration.md +2 -0
  4. data/documentation/General-PWN-Usage.md +68 -0
  5. data/documentation/Harness-Evaluation.md +312 -0
  6. data/documentation/pwn-ai-Agent.md +135 -6
  7. data/etc/default_cron/session_cleanup.rb +6 -1
  8. data/etc/default_skills/pwn/ai/agent/dispatch/SKILL.md +1 -0
  9. data/etc/default_skills/pwn/ai/agent/open_goal/SKILL.md +1 -0
  10. data/etc/default_skills/pwn/ai/agent/skill_review/SKILL.md +2 -0
  11. data/etc/default_skills/pwn/ai/agent/solve/SKILL.md +4 -2
  12. data/etc/default_skills/pwn/ai/context_ingestion/SKILL.md +1 -1
  13. data/etc/default_skills/pwn/ai/http_retry/SKILL.md +1 -0
  14. data/etc/default_skills/pwn/plugins/repl/ai_console/SKILL.md +1 -1
  15. data/etc/default_skills/pwn/plugins/repl/mesh/SKILL.md +4 -0
  16. data/etc/default_skills/pwn/plugins/repl/mesh/references/urls.md +3 -0
  17. data/etc/default_skills/pwn/redaction/SKILL.md +1 -1
  18. data/etc/default_skills/pwn/runtime_fingerprint/SKILL.md +47 -0
  19. data/lib/pwn/ai/agent/dispatch.rb +50 -6
  20. data/lib/pwn/ai/agent/loop.rb +192 -115
  21. data/lib/pwn/ai/agent/open_goal.rb +43 -1
  22. data/lib/pwn/ai/agent/registry.rb +6 -1
  23. data/lib/pwn/ai/agent/request_runtime.rb +508 -17
  24. data/lib/pwn/ai/agent/result.rb +23 -6
  25. data/lib/pwn/ai/agent/reward.rb +4 -1
  26. data/lib/pwn/ai/agent/skill_review.rb +148 -1
  27. data/lib/pwn/ai/agent/solve.rb +420 -39
  28. data/lib/pwn/ai/agent/swarm.rb +11 -2
  29. data/lib/pwn/ai/agent/tools/shell.rb +23 -0
  30. data/lib/pwn/ai/agent/turn_finalizer.rb +37 -6
  31. data/lib/pwn/ai/agent/verification.rb +42 -5
  32. data/lib/pwn/ai/anthropic.rb +59 -5
  33. data/lib/pwn/ai/cli.rb +48 -4
  34. data/lib/pwn/ai/context.rb +1 -1
  35. data/lib/pwn/ai/context_ingestion.rb +16 -1
  36. data/lib/pwn/ai/gemini.rb +36 -5
  37. data/lib/pwn/ai/grok.rb +19 -15
  38. data/lib/pwn/ai/http_retry.rb +14 -0
  39. data/lib/pwn/ai/mcp/combo_nation.rb +5 -2
  40. data/lib/pwn/ai/mcp.rb +5 -2
  41. data/lib/pwn/ai/ollama.rb +46 -5
  42. data/lib/pwn/ai/open_ai.rb +11 -11
  43. data/lib/pwn/ai/open_web_ui.rb +46 -5
  44. data/lib/pwn/config.rb +10 -2
  45. data/lib/pwn/migrate.rb +9 -1
  46. data/lib/pwn/plugins/repl/ai.rb +31 -1
  47. data/lib/pwn/plugins/repl/ai_console.rb +843 -19
  48. data/lib/pwn/plugins/repl/ai_console_commands.rb +23 -5
  49. data/lib/pwn/plugins/repl/ai_swarm.rb +65 -5
  50. data/lib/pwn/plugins/repl/mesh.rb +894 -83
  51. data/lib/pwn/plugins/repl.rb +2 -0
  52. data/lib/pwn/redaction.rb +29 -5
  53. data/lib/pwn/runtime_fingerprint.rb +84 -0
  54. data/lib/pwn/version.rb +1 -1
  55. data/lib/pwn.rb +1 -0
  56. data/spec/fixtures/harness/adapters.json +62 -0
  57. data/spec/fixtures/harness/adapters.json.sha256 +1 -0
  58. data/spec/fixtures/harness/experiment_gold.json +9 -0
  59. data/spec/fixtures/harness/experiment_gold.json.sha256 +1 -0
  60. data/spec/fixtures/harness/experiment_tasks.json +12 -0
  61. data/spec/fixtures/harness/experiment_tasks.json.sha256 +1 -0
  62. data/spec/fixtures/harness/live_comparison.json +21 -0
  63. data/spec/fixtures/harness/live_comparison.json.sha256 +1 -0
  64. data/spec/fixtures/harness/manifest.json +290 -0
  65. data/spec/fixtures/harness/manifest.json.sha256 +1 -0
  66. data/spec/integration/harness_evaluation_spec.rb +171 -0
  67. data/spec/integration/harness_layout_spec.rb +15 -0
  68. data/spec/integration/harness_live_spec.rb +26 -0
  69. data/spec/integration/harness_package_spec.rb +38 -0
  70. data/spec/integration/native_ruby_helpers_spec.rb +4 -2
  71. data/spec/integration/reinforced_feedback_loop_spec.rb +2 -2
  72. data/spec/lib/pwn/ai/agent/dispatch_spec.rb +47 -3
  73. data/spec/lib/pwn/ai/agent/loop_spec.rb +479 -17
  74. data/spec/lib/pwn/ai/agent/open_goal_spec.rb +17 -0
  75. data/spec/lib/pwn/ai/agent/registry_spec.rb +10 -0
  76. data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +232 -0
  77. data/spec/lib/pwn/ai/agent/result_spec.rb +33 -0
  78. data/spec/lib/pwn/ai/agent/signal_hygiene_spec.rb +1 -1
  79. data/spec/lib/pwn/ai/agent/skill_review_spec.rb +17 -5
  80. data/spec/lib/pwn/ai/agent/solve_spec.rb +392 -7
  81. data/spec/lib/pwn/ai/agent/swarm_spec.rb +19 -0
  82. data/spec/lib/pwn/ai/agent/tools/ruby_eval_spec.rb +2 -2
  83. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +20 -0
  84. data/spec/lib/pwn/ai/agent/verification_spec.rb +12 -0
  85. data/spec/lib/pwn/ai/anthropic_spec.rb +10 -0
  86. data/spec/lib/pwn/ai/cli_spec.rb +40 -0
  87. data/spec/lib/pwn/ai/context_ingestion_spec.rb +12 -0
  88. data/spec/lib/pwn/ai/grok_spec.rb +35 -0
  89. data/spec/lib/pwn/ai/mcp/combo_nation_spec.rb +10 -0
  90. data/spec/lib/pwn/ai/mcp_spec.rb +10 -0
  91. data/spec/lib/pwn/ai/open_ai_spec.rb +13 -0
  92. data/spec/lib/pwn/migrate_spec.rb +37 -4
  93. data/spec/lib/pwn/plugins/repl/ai_console_completion_wrap_spec.rb +110 -0
  94. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +307 -3
  95. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +29 -0
  96. data/spec/lib/pwn/plugins/repl/ai_vault_edit_spec.rb +221 -0
  97. data/spec/lib/pwn/plugins/repl_mesh_channel_identity_spec.rb +134 -0
  98. data/spec/lib/pwn/plugins/repl_mesh_dispatch_spec.rb +2 -0
  99. data/spec/lib/pwn/plugins/repl_mesh_dm_transport_spec.rb +2 -1
  100. data/spec/lib/pwn/plugins/repl_mesh_multi_rx_spec.rb +315 -0
  101. data/spec/lib/pwn/plugins/repl_mesh_origin_gps_spec.rb +120 -0
  102. data/spec/lib/pwn/plugins/repl_mesh_rx_errors_spec.rb +325 -0
  103. data/spec/lib/pwn/plugins/repl_mesh_scroll_spec.rb +49 -0
  104. data/spec/lib/pwn/plugins/repl_mesh_transport_label_spec.rb +43 -15
  105. data/spec/lib/pwn/plugins/repl_spec.rb +31 -7
  106. data/spec/lib/pwn/redaction_spec.rb +17 -0
  107. data/spec/lib/pwn/runtime_fingerprint_spec.rb +53 -0
  108. data/spec/lib/pwn/session_cleanup_default_spec.rb +1 -1
  109. data/spec/spec_helper.rb +3 -1
  110. data/spec/support/harness/cli_faults.rb +112 -0
  111. data/spec/support/harness/evaluate.rb +191 -0
  112. data/spec/support/harness/experiment_preload.rb +20 -0
  113. data/spec/support/harness/experiments.rb +137 -0
  114. data/spec/support/harness/fake_provider.rb +82 -0
  115. data/spec/support/harness/fault_preload.rb +44 -0
  116. data/spec/support/harness/package_acceptance.rb +77 -0
  117. data/spec/support/harness/record_training.rb +18 -0
  118. data/spec/support/harness/reference_adapter.py +128 -0
  119. data/spec/support/harness/runtime_faults.rb +97 -0
  120. data/spec/support/harness/session_acceptance.rb +70 -0
  121. data/spec/support/harness/swarm_acceptance.rb +61 -0
  122. data/third_party/pwn_rdoc.jsonl +129 -3
  123. metadata +46 -8
@@ -818,9 +818,11 @@ module PWN
818
818
 
819
819
  private_class_method def self.request_unsatisfied?(opts = {})
820
820
  request = opts[:request].to_s
821
+ return true if completion_unmet(request: request, messages: opts[:messages]).any?
822
+ return false if Thread.current[:pwn_request_runtime]&.scope_evidenced?
823
+
821
824
  need = request_need(request: request)
822
825
  return false if need == :none
823
- return false if Thread.current[:pwn_loop_no_tools]
824
826
 
825
827
  effects = tool_effects(messages: opts[:messages])
826
828
  return !effects.intersect?(%i[read recall eval]) if need == :read && catalog_lookup?(request: request)
@@ -829,41 +831,21 @@ module PWN
829
831
  live = effects.reject { |fx| %i[recall store].include?(fx) }
830
832
  unmet = completion_unmet(request: request, messages: opts[:messages])
831
833
  return true if unmet.any?
832
- return true if live.empty? && %i[write browse read].include?(need)
833
834
  return true if duration_unsatisfied?(request: request)
835
+ return false if need == :write && write_verified?(request: request, messages: opts[:messages])
836
+ return true if live.empty? && %i[write browse read].include?(need)
834
837
  return true if need == :write && !write_verified?(effects: effects, request: request, messages: opts[:messages])
835
838
  return true if need == :browse && !effects.include?(:browse)
836
839
  return true if need == :any && live.any? && !live.intersect?(%i[write browse eval])
837
840
 
838
841
  false
839
842
  rescue StandardError
840
- false
843
+ true
841
844
  end
842
845
 
843
846
  private_class_method def self.write_verified?(opts = {})
844
- effects = Array(opts[:effects])
845
- idx = effects.index(:write)
846
- if idx
847
- tail = effects[(idx + 1)..] || []
848
- return true if tail.intersect?(%i[read eval])
849
- end
850
-
851
- saw_write_argv = false
852
- Array(opts[:messages]).each do |msg|
853
- next unless msg.is_a?(Hash)
854
-
855
- if msg[:role].to_s == 'assistant' && defined?(Dispatch)
856
- Array(msg[:tool_calls]).each do |tc|
857
- blob = Dispatch.send(:argv_blob, args: tc.dig(:function, :arguments) || tc.dig('function', 'arguments'))
858
- saw_write_argv = true if blob.to_s.match?(Dispatch::WRITE_ARGV_RX)
859
- end
860
- elsif saw_write_argv && msg[:role].to_s == 'tool'
861
- fx = stamped_effect(content: msg[:content])
862
- return true if %i[read eval].include?(fx)
863
- end
864
- end
865
-
866
- false
847
+ # A generic later read/eval is not verification of this write.
848
+ TurnFinalizer.evidence_satisfied?(request: opts[:request], messages: opts[:messages])
867
849
  rescue StandardError
868
850
  false
869
851
  end
@@ -915,17 +897,20 @@ module PWN
915
897
  private_class_method def self.declared_contract_unsatisfied?(opts = {})
916
898
  completion_unmet(opts).any?
917
899
  rescue StandardError
918
- false
900
+ true
919
901
  end
920
902
 
921
903
  private_class_method def self.completion_unmet(opts = {})
922
904
  contract = declared_contract(request: opts[:request])
923
- request = opts[:request].to_s
905
+ request = verification_text(text: opts[:request])
924
906
  unmet = []
925
907
  files = Array(contract[:paths]) + Array(contract[:proofs])
926
908
  files.each { |path| unmet << "deliverable_missing:#{path}" if deliverable_missing?(path: path) }
927
909
  artifact = TurnFinalizer.arbitrate(request: request, paths: contract[:paths], messages: opts[:messages])
928
- artifact[:unmet].each { |row| unmet << "#{row[:criterion]}:#{row[:detail]}" }
910
+ artifact[:unmet].each do |row|
911
+ unmet << "#{verification_text(text: row[:criterion])}:#{verification_text(text: row[:detail])}"
912
+ end
913
+ unmet.concat(Thread.current[:pwn_request_runtime].unmet) if Thread.current[:pwn_request_runtime]
929
914
  trace_files = session_files(messages: opts[:messages])
930
915
  unmet << 'issue_work_proofs' if contract[:issue_work] && Array(contract[:proofs]).empty? && files.empty? && trace_files.empty?
931
916
  if contract[:issue_work] && defined?(PWN::Plugins::Findings)
@@ -935,14 +920,14 @@ module PWN
935
920
  end
936
921
  unmet << 'skills' if declared_skills_missing?(skills: contract[:skills], request: request)
937
922
  blob = evidence_blob(messages: opts[:messages], files: files + trace_files)
938
- asked_hosts = Array(contract[:hosts]).select { |host| request.downcase.include?(host.to_s.downcase) }
939
- asked_tech = Array(contract[:techniques]).select { |tech| request.downcase.include?(tech.to_s.downcase) }
940
- unmet << 'hosts' if evidence_tokens_missing?(tokens: asked_hosts, blob: blob)
923
+ asked_hosts = Array(contract[:hosts]).select { |host| request.downcase.include?(verification_text(text: host).downcase) }
924
+ asked_tech = Array(contract[:techniques]).select { |tech| request.downcase.include?(verification_text(text: tech).downcase) }
925
+ unmet << 'hosts' unless asked_hosts.all? { |host| Thread.current[:pwn_request_runtime]&.host_evidenced?(host) }
941
926
  unmet << 'techniques' if evidence_tokens_missing?(tokens: asked_tech, blob: blob)
942
927
  Array(Swarm.honesty_unmet(swarm_id: Thread.current[:pwn_swarm_id])).each { |u| unmet << u } if defined?(Swarm)
943
928
  unmet
944
- rescue StandardError
945
- []
929
+ rescue StandardError => e
930
+ ["verification_error:#{e.class}:#{verification_text(text: e.message)}"]
946
931
  end
947
932
 
948
933
  private_class_method def self.write_or_read_evidenced?(opts = {})
@@ -968,16 +953,20 @@ module PWN
968
953
  tokens = Array(opts[:tokens]).map(&:to_s).reject(&:empty?)
969
954
  return false if tokens.empty?
970
955
 
971
- blob = opts[:blob].to_s.downcase
972
- tokens.any? { |tok| !blob.include?(tok.downcase) }
956
+ blob = verification_text(text: opts[:blob]).downcase
957
+ tokens.any? { |tok| !blob.include?(verification_text(text: tok).downcase) }
958
+ end
959
+
960
+ private_class_method def self.verification_text(opts = {})
961
+ opts[:text].to_s.dup.force_encoding(Encoding::UTF_8).scrub
973
962
  end
974
963
 
975
964
  private_class_method def self.evidence_blob(opts = {})
976
965
  parts = Array(opts[:messages]).select { |msg| msg.is_a?(Hash) && %w[tool assistant].include?(msg[:role].to_s) }.map do |msg|
977
- unwrap_untrusted(text: msg[:content].to_s)
966
+ verification_text(text: unwrap_untrusted(text: msg[:content]))
978
967
  end
979
968
  Array(opts[:files]).each do |path|
980
- parts << File.binread(path) if File.file?(path.to_s)
969
+ parts << verification_text(text: File.binread(path)) if File.file?(path.to_s)
981
970
  rescue StandardError
982
971
  nil
983
972
  end
@@ -996,7 +985,7 @@ module PWN
996
985
  end
997
986
 
998
987
  private_class_method def self.unwrap_untrusted(opts = {})
999
- text = opts[:text].to_s
988
+ text = verification_text(text: opts[:text])
1000
989
  text = text.sub(/\A\[UNTRUSTED TOOL OUTPUT[^\]]*\]\s*/m, '')
1001
990
  text.sub(%r{\s*\[/UNTRUSTED TOOL OUTPUT\]\s*\z}m, '')
1002
991
  end
@@ -1010,10 +999,8 @@ module PWN
1010
999
  cached = Thread.current[:pwn_loop_deliverables]
1011
1000
  contract = if cached.is_a?(Array) || cached.is_a?(Hash)
1012
1001
  normalize_contract(raw: cached)
1013
- elsif Thread.current[:pwn_loop_nested] || !Thread.current[:pwn_loop_active]
1014
- EMPTY_CONTRACT.dup
1015
1002
  else
1016
- infer_deliverables(request: opts[:request])
1003
+ EMPTY_CONTRACT.dup
1017
1004
  end
1018
1005
  # Paths are user-owned. Inference cannot invent a destination or
1019
1006
  # convert the source document into a write obligation.
@@ -1139,8 +1126,6 @@ module PWN
1139
1126
  return false
1140
1127
  end
1141
1128
  end
1142
- return true if evidence_satisfied?(messages: opts[:messages], text: opts[:text], request: opts[:request])
1143
- return false if incomplete_final?(text: opts[:text], last_iter: false)
1144
1129
  return false if request_unsatisfied?(
1145
1130
  request: opts[:request],
1146
1131
  messages: opts[:messages],
@@ -1306,6 +1291,9 @@ module PWN
1306
1291
  end
1307
1292
 
1308
1293
  private_class_method def self.active_engine
1294
+ owner = Thread.current[:pwn_request_runtime]
1295
+ return owner.engine.to_sym if owner.respond_to?(:engine)
1296
+
1309
1297
  tl = Thread.current[:pwn_swarm_engine].to_s
1310
1298
  return tl.downcase.to_sym unless tl.empty?
1311
1299
 
@@ -2042,7 +2030,7 @@ module PWN
2042
2030
 
2043
2031
  private_class_method def self.thinking_effort(opts = {})
2044
2032
  engine_name = (opts[:engine] || active_engine).to_s.to_sym
2045
- slot = defined?(PWN::Env) ? PWN::Env.dig(:ai, engine_name) : nil
2033
+ slot = PWN::AI::HttpRetry.provider_config(engine: engine_name)
2046
2034
  slot = {} unless slot.is_a?(Hash)
2047
2035
  value = opts[:effort] || slot[:reasoning_effort] || slot[:think]
2048
2036
  text = value.to_s.strip
@@ -2060,7 +2048,7 @@ module PWN
2060
2048
  )
2061
2049
 
2062
2050
  runtime = Thread.current[:pwn_request_runtime]
2063
- if runtime.respond_to?(:call) && !Thread.current[:pwn_solve_tools]
2051
+ if runtime.respond_to?(:call)
2064
2052
  return runtime.call(messages: messages, tools: tools) do |prepared, route|
2065
2053
  invoke_provider_chat(engine: (route || {})[:provider] || active_engine, messages: prepared, tools: tools, model: (route || {})[:model], temp: (route || {})[:temperature])
2066
2054
  end
@@ -2078,6 +2066,7 @@ module PWN
2078
2066
 
2079
2067
  mod = Object.const_get(mod_name)
2080
2068
  if mod.respond_to?(:chat_with_tools)
2069
+ config = PWN::AI::HttpRetry.provider_config(engine: engine) || {}
2081
2070
  wire_msgs = if engine == :grok
2082
2071
  openai_wire_messages(messages: messages)
2083
2072
  elsif local_engine?(engine: engine)
@@ -2096,14 +2085,14 @@ module PWN
2096
2085
  cwt_opts[:think] = true
2097
2086
  else
2098
2087
  cwt_opts[:think] = false
2099
- cwt_opts[:reasoning_effort] = 'none' if engine == :openai && PWN::Env.dig(:ai, engine, :reasoning_effort).to_s == 'none'
2088
+ cwt_opts[:reasoning_effort] = 'none' if engine == :openai && config[:reasoning_effort].to_s == 'none'
2100
2089
  end
2101
- model = opts[:model] || Thread.current[:pwn_swarm_model]
2090
+ model = opts[:model] || Thread.current[:pwn_swarm_model] || config[:model]
2102
2091
  cwt_opts[:model] = model unless model.to_s.strip.empty?
2103
2092
  cwt_opts[:temp] = opts[:temp] unless opts[:temp].nil?
2104
2093
  if tools && !tools.empty?
2105
2094
  env_tc = begin
2106
- PWN::Env.dig(:ai, engine, :tool_choice)
2095
+ config[:tool_choice]
2107
2096
  rescue StandardError
2108
2097
  nil
2109
2098
  end
@@ -2594,7 +2583,7 @@ module PWN
2594
2583
  SYS
2595
2584
 
2596
2585
  txt =
2597
- if mod.respond_to?(:chat)
2586
+ if mod.respond_to?(:chat) && !Thread.current[:pwn_request_runtime]
2598
2587
  r = mod.chat(
2599
2588
  request: request,
2600
2589
  system_role_content: q_sys,
@@ -2660,7 +2649,7 @@ module PWN
2660
2649
  SYS
2661
2650
 
2662
2651
  txt =
2663
- if mod.respond_to?(:chat)
2652
+ if mod.respond_to?(:chat) && !Thread.current[:pwn_request_runtime]
2664
2653
  r = mod.chat(
2665
2654
  request: request,
2666
2655
  system_role_content: q_sys,
@@ -2752,7 +2741,7 @@ module PWN
2752
2741
  SYS
2753
2742
 
2754
2743
  txt =
2755
- if mod.respond_to?(:chat)
2744
+ if mod.respond_to?(:chat) && !Thread.current[:pwn_request_runtime]
2756
2745
  r = mod.chat(
2757
2746
  request: request,
2758
2747
  system_role_content: howto_sys,
@@ -3050,7 +3039,7 @@ module PWN
3050
3039
  SYS
3051
3040
 
3052
3041
  txt =
3053
- if mod.respond_to?(:chat)
3042
+ if mod.respond_to?(:chat) && !Thread.current[:pwn_request_runtime]
3054
3043
  r = mod.chat(
3055
3044
  request: request,
3056
3045
  system_role_content: recall_sys,
@@ -3104,7 +3093,15 @@ module PWN
3104
3093
  # )
3105
3094
 
3106
3095
  public_class_method def self.run(opts = {})
3107
- options = opts.merge(request: opts[:request])
3096
+ previous_runtime = Thread.current[:pwn_request_runtime]
3097
+ execution_lease = execution_lease(opts)
3098
+ runtime = opts[:runtime] || request_runtime(opts)
3099
+ Thread.current[:pwn_request_runtime] = runtime
3100
+ return 'Request cancelled.' if runtime.outcome&.dig(:status) == 'cancelled'
3101
+
3102
+ runtime.start!
3103
+ runtime.bind_verification!(contract: opts[:verification_contract])
3104
+ options = opts.merge(request: runtime.request, verification_contract: runtime.verification_contract)
3108
3105
  sid = opts[:session_id].to_s
3109
3106
  lease = PWN::Sessions.activate(session_id: sid) if !sid.empty? && File.file?(File.join(PWN::Sessions.sessions_dir, "#{sid}.jsonl"))
3110
3107
  original_system = opts[:system_role_content]
@@ -3115,16 +3112,95 @@ module PWN
3115
3112
  history.pop if history.last&.dig(:role).to_s == 'assistant' && Array(history.last[:tool_calls]).empty?
3116
3113
  repair_steering_history!(messages: history)
3117
3114
  e.instructions.each do |instruction|
3115
+ runtime.amend!(instruction: instruction)
3118
3116
  history << { role: 'user', content: instruction }
3119
3117
  append_session(session_id: options[:session_id], role: 'user', content: instruction)
3120
3118
  end
3121
- options = options.merge(request: e.instructions.last, steering_history: history, system_role_content: original_system)
3119
+ return 'Request cancelled.' if runtime.outcome&.dig(:status) == 'cancelled'
3120
+
3121
+ options = options.merge(request: runtime.request, steering_history: history, system_role_content: original_system)
3122
3122
  update_steered_open_goal!(options)
3123
- options.delete(:verification_contract)
3124
3123
  retry
3125
3124
  end
3125
+ rescue Interrupt
3126
+ runtime&.finish!(status: 'cancelled')
3127
+ raise
3126
3128
  ensure
3127
- PWN::Sessions.deactivate(lease: lease)
3129
+ begin
3130
+ runtime&.finish!(status: 'incomplete') unless runtime&.outcome
3131
+ previous_runtime.accept_child!(runtime: runtime) if previous_runtime && runtime && previous_runtime != runtime
3132
+ opts[:on_outcome]&.call(runtime.outcome) if runtime
3133
+ ensure
3134
+ Thread.current[:pwn_request_runtime] = previous_runtime
3135
+ PWN::Sessions.deactivate(lease: lease)
3136
+ execution_lease&.close
3137
+ end
3138
+ end
3139
+
3140
+ # Exclusive execution ownership is distinct from the shared cleanup
3141
+ # lease. Acquire before loading/checkpointing; crash closes the fd.
3142
+ private_class_method def self.execution_lease(opts = {})
3143
+ sid = (opts[:runtime]&.session_id || opts[:session_id]).to_s
3144
+ raise ArgumentError, 'runtime session identity mismatch' if opts[:runtime] && opts[:session_id] && opts[:session_id].to_s != sid
3145
+
3146
+ return if sid.empty?
3147
+
3148
+ root = PWN::Sessions.sessions_dir
3149
+ FileUtils.mkdir_p(root)
3150
+ lock = File.open(File.join(root, ".#{Digest::SHA256.hexdigest(sid)}.execution.lock"), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) # rubocop:disable Style/FileOpen -- Loop owns lifetime
3151
+ lock.close_on_exec = true
3152
+ raise IOError, "Session #{sid} is already executing" unless lock.flock(File::LOCK_EX | File::LOCK_NB)
3153
+
3154
+ lock
3155
+ rescue Exception # rubocop:disable Lint/RescueException -- close untransferred descriptor on interrupt
3156
+ lock&.close
3157
+ raise
3158
+ end
3159
+
3160
+ private_class_method def self.engine_failure_text(opts = {})
3161
+ attempts = opts[:runtime]&.last_provider_attempts || opts[:attempts]
3162
+ "[pwn-ai] engine hop failed after #{attempts} tries: #{opts[:error].message.to_s[0, 240]}"
3163
+ end
3164
+
3165
+ private_class_method def self.engine_retry_exhausted?(opts = {})
3166
+ !opts[:runtime].nil? || opts[:attempts] >= 5
3167
+ end
3168
+
3169
+ private_class_method def self.scheduled_tool_calls(opts = {})
3170
+ opts[:steering]&.checkpoint(messages: opts[:messages], phase: :tool)
3171
+ eligible = lambda do |call|
3172
+ args = { name: call.dig(:function, :name), args: call.dig(:function, :arguments) }
3173
+ signature = payload_sig(args)
3174
+ !Thread.current[:pwn_extinguished]&.[](signature) && (read_like?(args) || Thread.current[:pwn_same_payload].to_h.fetch(signature, 0) < 2)
3175
+ end
3176
+ opts[:runtime].scheduled_calls(opts[:calls], session_id: opts[:session_id], eligible: eligible)
3177
+ end
3178
+
3179
+ private_class_method def self.dispatch_scheduled(opts = {})
3180
+ opts[:result] || Dispatch.call(tool_call: opts[:tool_call], session_id: opts[:session_id])
3181
+ end
3182
+
3183
+ private_class_method def self.request_runtime(opts = {})
3184
+ goal = OpenGoal.for_session(session_id: opts[:session_id]) if opts[:request].to_s.match?(OpenGoal::CONTINUE_RX)
3185
+ raise ArgumentError, 'selected session no longer has an unfinished request' if opts[:require_resumable] && !goal
3186
+
3187
+ resume = goal && goal[:request_id] && goal[:checkpoint_path]
3188
+ root = resume ? File.dirname(goal[:checkpoint_path], 2) : opts[:artifact_root]
3189
+ RequestRuntime.new(request: goal ? goal[:request] : opts[:request], session_id: opts[:session_id],
3190
+ request_id: resume ? goal[:request_id] : opts[:request_id], artifact_root: root,
3191
+ resume: !!resume, trace: false, retrieve: false, enabled_toolsets: opts[:enabled_toolsets], verification_contract: opts[:verification_contract],
3192
+ engine: opts[:engine], model: opts[:model], provider_config: opts[:provider_config], profile: opts[:profile], profiles: opts[:profiles],
3193
+ provider_attempts: opts.fetch(:provider_attempts, 5), cancel: opts[:cancel])
3194
+ end
3195
+
3196
+ private_class_method def self.cheap_returns_allowed?(opts = {})
3197
+ opts[:force_tools] != true && !opts[:verification_contract]
3198
+ end
3199
+
3200
+ private_class_method def self.final_verification(opts = {})
3201
+ Reward.run_verification(request: opts[:request], session_id: opts[:session_id], contract: opts[:contract])
3202
+ rescue StandardError => e
3203
+ { status: :unknown, error: "#{e.class}: #{e.message}", checks: [] }
3128
3204
  end
3129
3205
 
3130
3206
  private_class_method def self.update_steered_open_goal!(opts = {})
@@ -3138,7 +3214,7 @@ module PWN
3138
3214
  system = opts[:system_role_content]
3139
3215
  history = opts[:steering_history]
3140
3216
  if history
3141
- authority = 'The operator has steered this request. Prior conversation and completed tool evidence remain context, not an obligation to continue cancelled work. Follow user instructions in chronological order; the latest explicit user instruction overrides earlier conflicting instructions. Reassess remaining work; never claim old work was undone.'
3217
+ authority = 'Steering adds constraints to the original request. Preserve all original obligations unless an explicit replace: amendment replaces the scope, or cancel ends it. Completed evidence remains valid for unchanged targets; never claim old work was undone.'
3142
3218
  return [{ role: 'system', content: "#{system}\n#{authority}" }] + history.reject { |row| row[:role].to_s == 'system' }
3143
3219
  end
3144
3220
  request = opts[:request]
@@ -3184,11 +3260,7 @@ module PWN
3184
3260
  opts[:messages] << {
3185
3261
  role: 'tool', tool_call_id: tc[:id], name: name,
3186
3262
  content: wrap_untrusted_tool(content: raw),
3187
- artifact_observations: TurnFinalizer.observe_artifacts(
3188
- paths: opts[:paths], before: opts[:before],
3189
- effect: Dispatch.effect(name: name, args: tc.dig(:function, :arguments)),
3190
- success: opts[:success]
3191
- )
3263
+ artifact_observations: opts[:observations] || {}
3192
3264
  }
3193
3265
  append_session(session_id: opts[:session_id], role: 'tool', content: "#{name} → #{raw}")
3194
3266
  opts[:on_tool]&.call(name, tc.dig(:function, :arguments), raw)
@@ -3242,7 +3314,12 @@ module PWN
3242
3314
  opts[:request] = request
3243
3315
  intent = prepared[:intent]
3244
3316
  Thread.current[:pwn_request_intent] = intent
3245
- TurnFinalizer.commit_artifacts!(request: request, inherit: nested)
3317
+ runtime = Thread.current[:pwn_request_runtime]
3318
+ if runtime && runtime.request == request
3319
+ Thread.current[:pwn_artifact_contract] = runtime.contract
3320
+ else
3321
+ TurnFinalizer.commit_artifacts!(request: request, inherit: nested)
3322
+ end
3246
3323
  required_artifacts = TurnFinalizer.required_artifacts(request: request)
3247
3324
  Thread.current[:pwn_extinguished] = {}
3248
3325
  Thread.current[:pwn_same_payload] = Hash.new(0)
@@ -3255,7 +3332,7 @@ module PWN
3255
3332
  expose_current_session(session_id: session_id)
3256
3333
  Mistakes.check_user_correction(request: request, session_id: session_id) if defined?(Mistakes)
3257
3334
 
3258
- allow_text_only = steering.nil? && required_artifacts.empty? && opts[:force_tools] != true
3335
+ allow_text_only = steering.nil? && required_artifacts.empty? && cheap_returns_allowed?(opts)
3259
3336
  cheap = allow_text_only && %i[greeting howto recall].include?(intent)
3260
3337
 
3261
3338
  if allow_text_only && intent == :greeting
@@ -3265,6 +3342,7 @@ module PWN
3265
3342
  request: request,
3266
3343
  session_id: session_id
3267
3344
  )
3345
+ runtime&.finish!(status: 'complete')
3268
3346
  final_chars = txt.to_s.length
3269
3347
  debug_final_text!(text: txt, debug: opts[:debug])
3270
3348
  return txt
@@ -3289,6 +3367,7 @@ module PWN
3289
3367
  session_id: session_id,
3290
3368
  system_role_content: system_role_content
3291
3369
  )
3370
+ runtime&.finish!(status: 'complete')
3292
3371
  final_chars = txt.to_s.length
3293
3372
  debug_final_text!(text: txt, debug: opts[:debug])
3294
3373
  return txt
@@ -3301,6 +3380,7 @@ module PWN
3301
3380
  session_id: session_id,
3302
3381
  system_role_content: system_role_content
3303
3382
  )
3383
+ runtime&.finish!(status: 'complete')
3304
3384
  final_chars = txt.to_s.length
3305
3385
  debug_final_text!(text: txt, debug: opts[:debug])
3306
3386
  return txt
@@ -3459,8 +3539,8 @@ module PWN
3459
3539
  if engine_transient?(error: e)
3460
3540
  engine_blips += 1
3461
3541
  debug_progress(msg: "engine hop failed #{e.class}: #{e.message.to_s[0, 240]} blip=#{engine_blips}")
3462
- if engine_blips >= 5
3463
- txt = "[pwn-ai] engine hop failed after #{engine_blips} tries: #{e.message.to_s[0, 240]}"
3542
+ if engine_retry_exhausted?(runtime: runtime, attempts: engine_blips)
3543
+ txt = engine_failure_text(runtime: runtime, attempts: engine_blips, error: e)
3464
3544
  debug_final_text!(text: txt)
3465
3545
  final_chars = txt.length
3466
3546
  return txt
@@ -3542,53 +3622,48 @@ module PWN
3542
3622
  return text if solve_handoff?(opts.merge(text: text, calls: calls))
3543
3623
 
3544
3624
  if calls.empty?
3545
- # P28 — refuse polite mid-goal handoffs so multi-step tasks stay autonomous.
3546
- if incomplete_final?(text: text, last_iter: false)
3547
- turn_fails['incomplete_final'] += 1
3548
- warn "[pwn-ai/loop] incomplete final on iter=#{i}; continuing autonomously"
3549
- debug_progress(msg: "bounce incomplete_final snippet=#{debug_snippet(text: text)}")
3550
- messages << {
3551
- role: 'user',
3552
- content: bounce_incomplete_nudge(text: text)
3553
- }
3554
- next
3625
+ verification_outcome = nil
3626
+ if opts[:verification_contract]
3627
+ report = final_verification(request: request, session_id: session_id, contract: opts[:verification_contract].merge(actions: verified_actions, answer: text))
3628
+ verification_outcome = Reward.resolve_outcome(outcome: { score: nil, source: :verification, verification: report })
3629
+ on_tool&.call('verification', {}, JSON.generate(report))
3630
+ unless report[:status].to_s == 'pass'
3631
+ status = report[:status].to_s == 'fail' ? 'incomplete' : 'verification_error'
3632
+ runtime&.finish!(status: status, unmet: [report])
3633
+ return "[pwn-ai] #{status}: explicit request verification did not pass."
3634
+ end
3555
3635
  end
3636
+ # Completion is an evidence decision, never a prose classifier.
3556
3637
  unless may_finalize?(
3557
3638
  request: request,
3558
3639
  messages: messages,
3559
3640
  text: text
3560
3641
  )
3561
3642
  turn_fails['unsatisfied'] += 1
3562
- if turn_fails['unsatisfied'] >= 2 && evidence_satisfied?(request: request, messages: messages, text: text)
3563
- debug_progress(msg: 'nag cap: evidence_satisfied after 2 bounces')
3564
- else
3565
- unmet = completion_unmet(request: request, messages: messages)
3566
- warn "[pwn-ai/loop] original request not evidenced on iter=#{i} unmet=#{unmet.join(',')}; continuing"
3567
- debug_progress(msg: "bounce unsatisfied unmet=#{unmet.join(',')} snippet=#{debug_snippet(text: text)}")
3568
- missing = unmet.any? { |row| row.to_s.start_with?('deliverable_missing:') }
3569
- nudge = if missing
3570
- "[pwn-ai] artifact not written; write it now. unmet=#{unmet.join(',')} " \
3571
- 'Keep calling CORE_TOOLS (shell, pwn_eval) until that path exists and is non-empty.'
3572
- else
3573
- "[pwn-ai] The original request is not evidenced yet. unmet=#{unmet.join(',')} " \
3574
- 'Keep calling CORE_TOOLS (shell, pwn_eval) until that request is ' \
3575
- 'done or a tool returned failure evidence. pwn-ai does not decide ' \
3576
- 'authorization. Do not declare completion from a listing or a refusal.'
3577
- end
3578
- messages << {
3579
- role: 'user',
3580
- content: nudge
3581
- }
3582
- next
3643
+ unmet = completion_unmet(request: request, messages: messages)
3644
+ if unmet.any? { |row| row.start_with?('verification_error:') }
3645
+ runtime&.finish!(status: 'verification_error', unmet: unmet)
3646
+ return "[pwn-ai] verification_error: #{unmet.join(',')}"
3583
3647
  end
3584
- end
3585
- verification_outcome = nil
3586
- if opts[:verification_contract]
3587
- report = Reward.run_verification(request: request, session_id: session_id, contract: opts[:verification_contract].merge(actions: verified_actions))
3588
- verification_outcome = Reward.resolve_outcome(outcome: { score: nil, source: :verification, verification: report })
3589
- on_tool&.call('verification', {}, JSON.generate(report))
3648
+ if no_tools
3649
+ runtime&.finish!(status: 'blocked', unmet: unmet + ['no_available_tools'])
3650
+ return '[pwn-ai] blocked: requested host work is unverified and no tools are available in this request.'
3651
+ end
3652
+ warn "[pwn-ai/loop] original request not evidenced on iter=#{i} unmet=#{unmet.join(',')}; continuing"
3653
+ debug_progress(msg: "bounce unsatisfied unmet=#{unmet.join(',')} snippet=#{debug_snippet(text: text)}")
3654
+ missing = unmet.any? { |row| row.to_s.start_with?('deliverable_missing:') }
3655
+ nudge = if missing
3656
+ "[pwn-ai] artifact not written; write it now. unmet=#{unmet.join(',')} " \
3657
+ 'Keep calling CORE_TOOLS (shell, pwn_eval) until that path exists and is non-empty.'
3658
+ else
3659
+ "[pwn-ai] The original request is not evidenced yet. unmet=#{unmet.join(',')} " \
3660
+ 'Continue remaining work, or report a concrete tool-evidenced blocker. Do not declare completion from unrelated evidence.'
3661
+ end
3662
+ messages << { role: 'user', content: nudge }
3663
+ next
3590
3664
  end
3591
3665
  steering&.checkpoint(messages: messages, phase: :finished)
3666
+ runtime&.finish!(status: 'complete')
3592
3667
  debug_progress(msg: "final accepted chars=#{text.to_s.length}")
3593
3668
  quiet_debug_tui!(reason: 'final')
3594
3669
  debug_final_text!(text: text)
@@ -3617,7 +3692,7 @@ module PWN
3617
3692
  on_tool: on_tool
3618
3693
  )
3619
3694
 
3620
- calls.each do |tc|
3695
+ scheduled_tool_calls(runtime: runtime, calls: calls, session_id: session_id, steering: steering, messages: messages).each do |tc, dispatched|
3621
3696
  steering&.checkpoint(messages: messages, phase: :tool)
3622
3697
  name = tc.dig(:function, :name).to_s
3623
3698
  args = tc.dig(:function, :arguments)
@@ -3638,11 +3713,10 @@ module PWN
3638
3713
  raw = if same_n >= 3
3639
3714
  checkpoint_result(name: name, args: args)
3640
3715
  else
3641
- Dispatch.call(tool_call: tc, session_id: session_id)
3716
+ dispatch_scheduled(tool_call: tc, session_id: session_id, result: dispatched)
3642
3717
  end
3643
3718
  end
3644
3719
  tools_called += 1
3645
- steering_tool_checkpoint!(steering: steering, messages: messages, tool_call: tc, raw: raw, paths: watch_paths, before: before_host, session_id: session_id, on_tool: on_tool)
3646
3720
  note_payload_result!(name: name, args: args, raw: raw)
3647
3721
  if opts[:verification_contract]
3648
3722
  after_artifacts = Verification.snapshot(opts[:verification_contract])
@@ -3652,8 +3726,12 @@ module PWN
3652
3726
  tele = record_metrics(name: name, action_id: tc[:id], trusted_context: Policy.current_episode&.dig(:trusted_context) || trusted_context,
3653
3727
  started: started, raw: raw, args: args, session_id: session_id, engine: engine, ts_state: ts_state)
3654
3728
  result = Result.condition(content: raw, entry: entry, session_id: session_id)
3655
-
3656
- steering_tool_checkpoint!(steering: steering, messages: messages, tool_call: tc, raw: result, paths: watch_paths, before: before_host, session_id: session_id, success: tele[:ok], on_tool: on_tool)
3729
+ observations = TurnFinalizer.observe_artifacts(
3730
+ paths: watch_paths, before: before_host,
3731
+ effect: Dispatch.effect(name: name, args: args), success: tele[:ok] == true,
3732
+ action_id: tc[:id], tool: name
3733
+ )
3734
+ steering_tool_checkpoint!(steering: steering, messages: messages, tool_call: tc, raw: result, observations: observations, session_id: session_id, on_tool: on_tool)
3657
3735
 
3658
3736
  unless tele[:ok]
3659
3737
  fkey = Digest::SHA256.hexdigest("#{name}|#{args}")[0, 16]
@@ -3686,12 +3764,7 @@ module PWN
3686
3764
  tool_call_id: tc[:id] || tc['id'] || "call_#{i}",
3687
3765
  name: name,
3688
3766
  content: wrap_untrusted_tool(content: result),
3689
- artifact_observations: TurnFinalizer.observe_artifacts(
3690
- paths: watch_paths,
3691
- before: before_host,
3692
- effect: Dispatch.effect(name: name, args: args),
3693
- success: tele[:ok] == true
3694
- )
3767
+ artifact_observations: observations
3695
3768
  }
3696
3769
  append_session(
3697
3770
  session_id: session_id,
@@ -3863,6 +3936,10 @@ module PWN
3863
3936
  #{self}.run(
3864
3937
  request: 'required - what the human typed',
3865
3938
  session_id: 'optional - PWN::Sessions id (transcript is appended to it)',
3939
+ runtime: 'optional - request-owned RequestRuntime, including a restored checkpoint',
3940
+ request_id: 'optional - stable request identity for the durable evidence checkpoint',
3941
+ artifact_root: 'optional - private request checkpoint and context-spill directory',
3942
+ on_outcome: 'optional - callback receiving the persisted structured terminal outcome',
3866
3943
  enabled_toolsets: 'optional - subset of Registry.toolsets, or nil for all',
3867
3944
  on_tool: 'optional - ->(name, args, result) callback for live UI',
3868
3945
  system_role_content: 'optional - override default system prompt (built from session_id if not provided)',