pwn 0.5.667 → 0.5.669

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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +1 -0
  3. data/CONTRIBUTING.md +15 -0
  4. data/Gemfile +4 -4
  5. data/README.md +4 -4
  6. data/documentation/AI-Integration.md +5 -3
  7. data/documentation/Agent-Tool-Registry.md +18 -14
  8. data/documentation/Configuration.md +2 -0
  9. data/documentation/Contributing.md +13 -0
  10. data/documentation/Diagrams.md +1 -1
  11. data/documentation/Home.md +4 -4
  12. data/documentation/How-PWN-Works.md +6 -6
  13. data/documentation/Persistence.md +17 -0
  14. data/documentation/Reinforcement-Learning.md +39 -39
  15. data/documentation/SDR.md +1 -1
  16. data/documentation/Sessions.md +1 -0
  17. data/documentation/Skills-Memory-Learning.md +2 -0
  18. data/documentation/What-is-PWN.md +3 -3
  19. data/documentation/diagrams/agent-tool-registry.svg +43 -43
  20. data/documentation/diagrams/ai-integration-tool-calling.svg +112 -85
  21. data/documentation/diagrams/dot/agent-tool-registry.dot +4 -4
  22. data/documentation/diagrams/dot/ai-integration-tool-calling.dot +7 -3
  23. data/documentation/diagrams/dot/memory-skills-detailed.dot +2 -2
  24. data/documentation/diagrams/dot/overall-pwn-architecture.dot +6 -4
  25. data/documentation/diagrams/dot/persistence-filesystem.dot +1 -1
  26. data/documentation/diagrams/dot/sdr-radio-flow.dot +1 -1
  27. data/documentation/diagrams/memory-skills-detailed.svg +182 -182
  28. data/documentation/diagrams/overall-pwn-architecture.svg +154 -146
  29. data/documentation/diagrams/persistence-filesystem.svg +6 -6
  30. data/documentation/diagrams/sdr-radio-flow.svg +1 -1
  31. data/documentation/pwn-ai-Agent.md +29 -13
  32. data/lib/pwn/ai/agent/dispatch.rb +161 -0
  33. data/lib/pwn/ai/agent/extrospection.rb +9 -5
  34. data/lib/pwn/ai/agent/learning.rb +2 -1
  35. data/lib/pwn/ai/agent/loop.rb +500 -19
  36. data/lib/pwn/ai/agent/prompt_builder.rb +36 -10
  37. data/lib/pwn/ai/agent/reflect.rb +1 -0
  38. data/lib/pwn/ai/agent/registry.rb +1 -1
  39. data/lib/pwn/ai/agent/result.rb +3 -3
  40. data/lib/pwn/ai/agent/swarm.rb +1 -1
  41. data/lib/pwn/ai/agent/task_summarizer.rb +41 -3
  42. data/lib/pwn/ai/agent/tools/memory.rb +18 -4
  43. data/lib/pwn/ai/agent/tools/shell.rb +32 -0
  44. data/lib/pwn/ai/grok.rb +3 -0
  45. data/lib/pwn/ai/ollama.rb +131 -78
  46. data/lib/pwn/ai/open_ai.rb +3 -0
  47. data/lib/pwn/ai/open_web_ui.rb +715 -0
  48. data/lib/pwn/ai.rb +1 -0
  49. data/lib/pwn/config.rb +70 -36
  50. data/lib/pwn/memory.rb +140 -11
  51. data/lib/pwn/memory_index.rb +82 -21
  52. data/lib/pwn/plugins/repl.rb +42 -11
  53. data/lib/pwn/version.rb +1 -1
  54. data/spec/integration/reinforced_feedback_loop_spec.rb +8 -6
  55. data/spec/lib/pwn/ai/agent/dispatch_spec.rb +78 -0
  56. data/spec/lib/pwn/ai/agent/extrospection_spec.rb +36 -0
  57. data/spec/lib/pwn/ai/agent/loop_spec.rb +148 -0
  58. data/spec/lib/pwn/ai/agent/task_summarizer_spec.rb +10 -0
  59. data/spec/lib/pwn/ai/agent/tools/shell_spec.rb +21 -0
  60. data/spec/lib/pwn/ai/grok_spec.rb +5 -0
  61. data/spec/lib/pwn/ai/ollama_spec.rb +36 -0
  62. data/spec/lib/pwn/ai/open_ai_spec.rb +5 -0
  63. data/spec/lib/pwn/ai/open_web_ui_spec.rb +302 -0
  64. data/spec/lib/pwn/config_spec.rb +70 -0
  65. data/spec/lib/pwn/memory_spec.rb +63 -0
  66. data/spec/lib/pwn/plugins/repl_pwn_vault_spec.rb +134 -0
  67. data/third_party/pwn_rdoc.jsonl +37 -5
  68. metadata +12 -9
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'securerandom'
4
5
  require 'digest'
5
6
  require 'pwn/ai/agent/mistakes'
6
7
 
@@ -60,6 +61,7 @@ module PWN
60
61
  openai: 'PWN::AI::OpenAI',
61
62
  grok: 'PWN::AI::Grok',
62
63
  ollama: 'PWN::AI::Ollama',
64
+ openwebui: 'PWN::AI::OpenWebUI',
63
65
  anthropic: 'PWN::AI::Anthropic',
64
66
  gemini: 'PWN::AI::Gemini'
65
67
  }.freeze
@@ -237,6 +239,11 @@ module PWN
237
239
  # goal was done ("shall I proceed?", "next step:", "want me to…").
238
240
  # Loop.run treats no-tool_calls as FINAL; this detector lets us refuse
239
241
  # that handoff and keep the tool loop alive for multi-step autonomy.
242
+ #
243
+ # Local/thinking models (gemma/Qwen abliterated etc.) often emit a
244
+ # monologue that NARRATES the next tool ("Wait, let's try hping3…")
245
+ # without producing native tool_calls or shell(...). Treat that as
246
+ # incomplete too so the loop re-pressures tools instead of FINAL.
240
247
  INCOMPLETE_FINAL_RX = /
241
248
  \b(shall\s+i|should\s+i|may\s+i|can\s+i|want\s+me\s+to|do\s+you\s+want\s+me|
242
249
  next\s+single\s+step|next\s+step\s*:|awaiting\s+your\s+(ok|approval|go-ahead|confirmation)|
@@ -248,6 +255,21 @@ module PWN
248
255
  )\b
249
256
  /ix
250
257
 
258
+ # Narrated-intent monologue without a structured tool call.
259
+ # Distinct from INCOMPLETE_FINAL_RX (polite handoff to the human).
260
+ MONOLOGUE_TOOL_INTENT_RX = /
261
+ \b(
262
+ wait[,\s]+let'?s\s+try|
263
+ let'?s\s+try\s+(one|to|again|hping|nmap|ping|sudo|shell|running|checking)|
264
+ i\s+(?:will|'ll)\s+(?:just\s+)?(?:try|run|check|probe|scan|use)\b|
265
+ actually,?\s+i\s+will\b|
266
+ one\s+more\s+thing\b|
267
+ if\s+it\s+fails\b.{0,80}\bthen\s+we\s+can\b|
268
+ verification\s+complete\b|
269
+ report\s+that\s+(?:the\s+)?verification\s+failed\b
270
+ )
271
+ /ix
272
+
251
273
  private_class_method def self.incomplete_final?(opts = {})
252
274
  text = opts[:text].to_s
253
275
  return false if text.strip.empty?
@@ -258,6 +280,22 @@ module PWN
258
280
  return true if text.include?('?') && text.length < 900 &&
259
281
  text.match?(/\b(proceed|continue|confirm|apply|next)\b/i)
260
282
 
283
+ # Thinking/monologue that plans tools in prose (esp. Ollama gemma).
284
+ # Cap length so a real long final answer is not bounced forever.
285
+ if text.length < 12_000 && text.match?(MONOLOGUE_TOOL_INTENT_RX)
286
+ # Prefer bounce when no concrete shell(...) / tool call is embedded.
287
+ return true unless defined?(Dispatch) && Dispatch.respond_to?(:tool_calls_from_text)
288
+ return true if Dispatch.tool_calls_from_text(text: text).empty?
289
+
290
+ end
291
+
292
+ # Degenerate repetition ("Wait, let's try…" loop) always incomplete.
293
+ lines = text.lines.map { |l| l.strip.downcase }.reject(&:empty?)
294
+ if lines.length >= 6
295
+ uniq_ratio = lines.uniq.length.to_f / lines.length
296
+ return true if uniq_ratio < 0.35
297
+ end
298
+
261
299
  false
262
300
  rescue StandardError
263
301
  false
@@ -268,7 +306,7 @@ module PWN
268
306
  n = v.to_i.positive? ? v.to_i : DEFAULT_MAX_ITERS
269
307
  # 0.3 — frontier leakage: live max_iters=80 burns local models.
270
308
  # Cap ollama at 25 unless the operator set an explicit lower value.
271
- n = 75 if active_engine == :ollama && n > 75
309
+ n = 75 if local_engine? && n > 75
272
310
  # P7 — W3 controller: when this engine is badly overconfident,
273
311
  # shrink the tool budget so thrash can't compound on bad plans.
274
312
  cal = calibration_state
@@ -282,7 +320,7 @@ module PWN
282
320
  # refuse polite handoffs and finish early when evidence is enough.
283
321
  # CF / red_team forks stay suppressed while hot.
284
322
  if budget_exhaustion_hot?
285
- hot_cap = active_engine == :ollama ? 24 : 75
323
+ hot_cap = local_engine? ? 24 : 75
286
324
  n = [n, hot_cap].min
287
325
  end
288
326
  n
@@ -311,7 +349,7 @@ module PWN
311
349
  remote_cap = 120
312
350
  local_cap = 24
313
351
  cap = if bad
314
- (eng == :ollama ? local_cap : remote_cap)
352
+ (local_engine?(engine: eng) ? local_cap : remote_cap)
315
353
  else
316
354
  75
317
355
  end
@@ -326,6 +364,16 @@ module PWN
326
364
  { overconfident: false, force_plan: false, force_critic: false, max_iters_cap: 75 }
327
365
  end
328
366
 
367
+ # Local/on-box engines share tight-context scaffolding (plan_first,
368
+ # tool_router, result caps, tool_choice pressure). Both :ollama
369
+ # (native server) and :openwebui (gateway in front of models) count.
370
+ private_class_method def self.local_engine?(opts = {})
371
+ eng = opts.key?(:engine) ? opts[:engine].to_s.downcase.to_sym : active_engine
372
+ %i[ollama openwebui].include?(eng)
373
+ rescue StandardError
374
+ false
375
+ end
376
+
329
377
  private_class_method def self.active_engine
330
378
  e = (PWN::Env.dig(:ai, :active) if defined?(PWN::Env)).to_s.downcase.to_sym
331
379
  e == :'' ? :openai : e
@@ -452,7 +500,7 @@ module PWN
452
500
  rescue StandardError
453
501
  false
454
502
  end
455
- plan_prompt = if hot && active_engine == :ollama
503
+ plan_prompt = if hot && local_engine?
456
504
  # Local hot: ultra-short plan — finish-under-8 is the skill gap.
457
505
  'Before acting: write AT MOST 3 numbered tool calls (name + key args) that finish the ask. Prefer fewer. LAST line: "p(success)=<0.0-1.0>". Reply ONLY with the plan + that line — no tools, no prose.'
458
506
  elsif hot
@@ -587,6 +635,184 @@ module PWN
587
635
  warn "[pwn-ai/loop] publish_usage swallowed: #{e.class}: #{e.message}"
588
636
  end
589
637
 
638
+ # Ollama / Open WebUI proxied /ollama/api/chat expect function.arguments
639
+ # as a JSON *object* (map), not a JSON-encoded string. Loop.normalize_llm
640
+ # and openai_wire_tool_call stringify for OpenAI/xAI; replaying that
641
+ # history into local engines produces HTTP 400:
642
+ # {"detail":"Value looks like object, but can't find closing '}' symbol"}
643
+ # because the gateway parses the string as if it were raw object text.
644
+ private_class_method def self.parse_tool_arguments(opts = {})
645
+ raw = opts[:arguments]
646
+ case raw
647
+ when Hash, Array
648
+ # Object form (Hash) is Ollama-native; Array is rare but keep as-is.
649
+ raw
650
+ when nil
651
+ {}
652
+ when String
653
+ s = raw.strip
654
+ return {} if s.empty?
655
+
656
+ begin
657
+ parsed = JSON.parse(s, symbolize_names: true)
658
+ return parsed if parsed.is_a?(Hash) || parsed.is_a?(Array)
659
+ rescue JSON::ParserError
660
+ # fall through
661
+ end
662
+ # Non-JSON bare string — wrap so the schema still gets an object.
663
+ { value: s }
664
+ else
665
+ { value: raw.to_s }
666
+ end
667
+ end
668
+
669
+ private_class_method def self.ollama_wire_tool_call(opts = {})
670
+ tc = opts[:tool_call]
671
+ return nil unless tc.is_a?(Hash)
672
+
673
+ fn = tc[:function] || tc['function'] || {}
674
+ name = fn[:name] || fn['name'] || tc[:name] || tc['name']
675
+ args = fn[:arguments] || fn['arguments'] || tc[:arguments] || tc['arguments']
676
+ {
677
+ id: (tc[:id] || tc['id'] || "call_#{SecureRandom.hex(4)}").to_s,
678
+ type: (tc[:type] || tc['type'] || 'function').to_s,
679
+ function: {
680
+ name: name.to_s,
681
+ arguments: parse_tool_arguments(arguments: args)
682
+ }
683
+ }
684
+ end
685
+
686
+ # Supported Method Parameters::
687
+ # wire = PWN::AI::Agent::Loop.ollama_wire_messages(
688
+ # messages: 'required - in-memory OpenAI-ish messages (may have String args)'
689
+ # )
690
+ #
691
+ # Returns a deep-copied array safe for Ollama / Open WebUI ollama/api/chat:
692
+ # - parses JSON-string function.arguments into Hash/Array objects
693
+ # - coerces nil assistant content to '' when tool_calls present
694
+ # (Open WebUI GenerateChatCompletionForm rejects content:null alone)
695
+ # - drops _native_content / _text_tool_coerced / thinking private keys
696
+ # - stringifies Hash/Array message content (tool results) to JSON text
697
+
698
+ public_class_method def self.ollama_wire_messages(opts = {})
699
+ messages = opts[:messages]
700
+ Array(messages).filter_map do |m|
701
+ next unless m.is_a?(Hash)
702
+
703
+ role = (m[:role] || m['role']).to_s
704
+ out = { role: role }
705
+
706
+ tcs = m[:tool_calls] || m['tool_calls']
707
+ wired_tcs = nil
708
+ if tcs
709
+ wired_tcs = Array(tcs).filter_map { |tc| ollama_wire_tool_call(tool_call: tc) }
710
+ out[:tool_calls] = wired_tcs unless wired_tcs.empty?
711
+ end
712
+
713
+ if m.key?(:content) || m.key?('content')
714
+ content = m.key?(:content) ? m[:content] : m['content']
715
+ out[:content] = case content
716
+ when nil
717
+ # Open WebUI: null content without tool_calls 400s;
718
+ # with tool_calls prefer "" over null.
719
+ wired_tcs && !wired_tcs.empty? ? '' : nil
720
+ when String then content
721
+ when Hash, Array then JSON.generate(content)
722
+ else content.to_s
723
+ end
724
+ elsif wired_tcs && !wired_tcs.empty?
725
+ out[:content] = ''
726
+ end
727
+
728
+ name = m[:name] || m['name']
729
+ out[:name] = name.to_s if name && !name.to_s.empty?
730
+
731
+ tcid = m[:tool_call_id] || m['tool_call_id']
732
+ out[:tool_call_id] = tcid.to_s if tcid && !tcid.to_s.empty?
733
+
734
+ out
735
+ end
736
+ end
737
+
738
+ # OpenAI-compatible wire format (xAI Grok / OpenAI chat.completions):
739
+ # function.arguments MUST be a JSON string, not a map; message.content
740
+ # MUST be a string (or null). Internal Hash arguments from Ollama or
741
+ # Dispatch.tool_calls_from_text caused:
742
+ # 422 Unprocessable Entity: messages[N]: invalid type: map, expected a string
743
+ private_class_method def self.stringify_tool_arguments(opts = {})
744
+ raw = opts[:arguments]
745
+ case raw
746
+ when String then raw.empty? ? '{}' : raw
747
+ when Hash, Array then JSON.generate(raw)
748
+ when nil then '{}'
749
+ else
750
+ s = raw.to_s
751
+ s.empty? ? '{}' : s
752
+ end
753
+ end
754
+
755
+ private_class_method def self.openai_wire_tool_call(opts = {})
756
+ tc = opts[:tool_call]
757
+ return nil unless tc.is_a?(Hash)
758
+
759
+ fn = tc[:function] || tc['function'] || {}
760
+ name = fn[:name] || fn['name'] || tc[:name] || tc['name']
761
+ args = fn[:arguments] || fn['arguments'] || tc[:arguments] || tc['arguments']
762
+ {
763
+ id: (tc[:id] || tc['id'] || "call_#{SecureRandom.hex(4)}").to_s,
764
+ type: (tc[:type] || tc['type'] || 'function').to_s,
765
+ function: {
766
+ name: name.to_s,
767
+ arguments: stringify_tool_arguments(arguments: args)
768
+ }
769
+ }
770
+ end
771
+
772
+ # Supported Method Parameters::
773
+ # wire = PWN::AI::Agent::Loop.openai_wire_messages(
774
+ # messages: 'required - in-memory OpenAI-ish messages (may have Hash args / internal keys)'
775
+ # )
776
+ #
777
+ # Returns a deep-copied array safe for OpenAI / xAI chat.completions:
778
+ # - drops _native_content / _text_tool_coerced / thinking private keys
779
+ # - stringifies function.arguments maps
780
+ # - coerces Hash/non-string content to JSON/string (nil kept for assistant tool turns)
781
+
782
+ public_class_method def self.openai_wire_messages(opts = {})
783
+ messages = opts[:messages]
784
+ Array(messages).filter_map do |m|
785
+ next unless m.is_a?(Hash)
786
+
787
+ role = (m[:role] || m['role']).to_s
788
+ out = { role: role }
789
+
790
+ if m.key?(:content) || m.key?('content')
791
+ content = m.key?(:content) ? m[:content] : m['content']
792
+ out[:content] = case content
793
+ when nil then nil
794
+ when String then content
795
+ when Hash, Array then JSON.generate(content)
796
+ else content.to_s
797
+ end
798
+ end
799
+
800
+ name = m[:name] || m['name']
801
+ out[:name] = name.to_s if name && !name.to_s.empty?
802
+
803
+ tcid = m[:tool_call_id] || m['tool_call_id']
804
+ out[:tool_call_id] = tcid.to_s if tcid && !tcid.to_s.empty?
805
+
806
+ tcs = m[:tool_calls] || m['tool_calls']
807
+ if tcs
808
+ wired = Array(tcs).filter_map { |tc| openai_wire_tool_call(tool_call: tc) }
809
+ out[:tool_calls] = wired unless wired.empty?
810
+ end
811
+
812
+ out
813
+ end
814
+ end
815
+
590
816
  # Supported Method Parameters::
591
817
  # msg = PWN::AI::Agent::Loop.normalize_llm(
592
818
  # response: 'required - chat_with_tools response Hash from any provider'
@@ -618,7 +844,9 @@ module PWN
618
844
  type: 'function',
619
845
  function: {
620
846
  name: tc.dig(:function, :name) || tc[:name],
621
- arguments: tc.dig(:function, :arguments) || tc[:arguments]
847
+ arguments: stringify_tool_arguments(
848
+ arguments: tc.dig(:function, :arguments) || tc[:arguments]
849
+ )
622
850
  }
623
851
  }
624
852
  end
@@ -628,6 +856,17 @@ module PWN
628
856
  # original tool_use block to precede a tool_result).
629
857
  out[:_native_content] = msg[:_native_content] if msg[:_native_content]
630
858
  out[:thinking] = msg[:thinking] if msg[:thinking]
859
+ # Local/abliterated models sometimes emit shell(...) as plain content
860
+ # with empty tool_calls. Coerce registered call-shaped text into
861
+ # structured tool_calls so Loop dispatches instead of FINAL-answering.
862
+ if out[:tool_calls].empty? && defined?(Dispatch) && Dispatch.respond_to?(:tool_calls_from_text)
863
+ coerced = Dispatch.tool_calls_from_text(text: out[:content].to_s)
864
+ if coerced.any?
865
+ out[:tool_calls] = coerced.map { |tc| openai_wire_tool_call(tool_call: tc) }
866
+ out[:content] = nil
867
+ out[:_text_tool_coerced] = true
868
+ end
869
+ end
631
870
  out
632
871
  end
633
872
 
@@ -652,11 +891,50 @@ module PWN
652
891
 
653
892
  mod = Object.const_get(mod_name)
654
893
  if mod.respond_to?(:chat_with_tools)
655
- response = mod.chat_with_tools(
656
- messages: messages,
894
+ # xAI/OpenAI reject Hash function.arguments / Hash content (422 map→string).
895
+ # Ollama / Open WebUI reject *string* function.arguments (HTTP 400
896
+ # "can't find closing '}' symbol") — opposite of OpenAI wire form.
897
+ wire_msgs = if %i[grok openai].include?(engine)
898
+ openai_wire_messages(messages: messages)
899
+ elsif local_engine?(engine: engine)
900
+ ollama_wire_messages(messages: messages)
901
+ else
902
+ messages
903
+ end
904
+ cwt_opts = {
905
+ messages: wire_msgs,
657
906
  tools: tools,
658
907
  spinner: true
659
- )
908
+ }
909
+ # Ollama + abliterated / weak chat-templates often ignore tools: and
910
+ # answer in prose (or print shell(...) as text). Force native
911
+ # tool_calls until at least one tool result is already in history;
912
+ # after that, auto so the model can emit a real final answer.
913
+ # Respect explicit PWN::Env[:ai][:ollama][:tool_choice] override.
914
+ if local_engine?(engine: engine) && tools && !tools.empty?
915
+ env_tc = begin
916
+ PWN::Env.dig(:ai, engine, :tool_choice)
917
+ rescue StandardError
918
+ nil
919
+ end
920
+ if env_tc && !env_tc.to_s.empty?
921
+ cwt_opts[:tool_choice] = env_tc
922
+ else
923
+ # Weak chat templates (TEMPLATE {{ .Prompt }} on abliterated
924
+ # Gemma etc.) ignore tools: under tool_choice=auto and dump
925
+ # monologue as content. Stay on required until a tool result
926
+ # exists AND the last assistant turn already looks like a
927
+ # genuine final (no monologue / handoff markers). That keeps
928
+ # pressure on native tool_calls through the mid-loop thrash
929
+ # that previously returned "Wait, let's try hping3…" as FINAL.
930
+ has_tool_result = Array(messages).any? { |m| m[:role].to_s == 'tool' }
931
+ last_asst = Array(messages).reverse.find { |m| m[:role].to_s == 'assistant' }
932
+ last_txt = last_asst.is_a?(Hash) ? last_asst[:content].to_s : ''
933
+ still_acting = last_txt.strip.empty? || incomplete_final?(text: last_txt, last_iter: false)
934
+ cwt_opts[:tool_choice] = has_tool_result && !still_acting ? 'auto' : 'required'
935
+ end
936
+ end
937
+ response = mod.chat_with_tools(**cwt_opts)
660
938
  publish_usage(response: response, engine: engine)
661
939
  normalize_llm(response: response)
662
940
  else
@@ -826,6 +1104,175 @@ module PWN
826
1104
  nil
827
1105
  end
828
1106
 
1107
+ # Request intent for routing (how-to vs act/recon). Local models thrash when
1108
+ # pure explanation asks are force-planned into multi-step host probes.
1109
+ # :howto → answer with explanation only (no plan_first / no live recon).
1110
+ # :recon_act → live discovery; requires explicit authorization language.
1111
+ # :act → general agent work with tools.
1112
+ HOWTO_RX = /
1113
+ \b(
1114
+ how\s+to|how\s+do\s+i|how\s+can\s+i|how\s+would\s+i|how\s+does\s+one|
1115
+ what\s+is\s+the\s+(?:syntax|command|usage|flag|option)|
1116
+ explain\s+how|show\s+me\s+how|examples?\s+of\s+using|
1117
+ manual\s+for|usage\s+of|syntax\s+for|man\s+page
1118
+ )\b
1119
+ /ix
1120
+
1121
+ LIVE_RECON_RX = /
1122
+ \b(
1123
+ (?:find|discover|enumerate|scan|sweep|probe|map)\s+
1124
+ (?:live\s+)?(?:hosts?|ips?|targets?|subnet|network|range)|
1125
+ live\s+hosts?\s+(?:can\s+you\s+)?find|
1126
+ what\s+live\s+hosts|
1127
+ ping\s+sweep\s+(?:of\s+)?(?:this|the|my)\s+
1128
+ |(?:run|do|perform)\s+(?:a\s+)?(?:ping\s+)?sweep
1129
+ |scan\s+(?:this|the|my)\s+(?:subnet|network|lan|range)
1130
+ )\b
1131
+ /ix
1132
+
1133
+ AUTH_SCOPE_RX = /
1134
+ \b(
1135
+ (?:in[-\s]?scope|authorized|authorised|engagement|written\s+permission|
1136
+ bug\s*bounty|explicit(?:ly)?\s+allowed|lab\s+only|my\s+lab|
1137
+ roe\b|rules?\s+of\s+engagement|i\s+own\s+this|owned\s+by\s+me|
1138
+ permission\s+to\s+(?:scan|test|probe)|scope:\s*\S+)
1139
+ )\b
1140
+ /ix
1141
+
1142
+ public_class_method def self.request_intent(opts = {})
1143
+ req = opts[:request].to_s
1144
+ return :empty if req.strip.empty?
1145
+
1146
+ # Live-action recon takes precedence over bare "how to" when both appear
1147
+ # only if the user clearly asks the agent to do the sweep here.
1148
+ live = req.match?(LIVE_RECON_RX) && req.match?(
1149
+ /\b(can\s+you|could\s+you|please|go\s+ahead|now|on\s+this\s+host|
1150
+ this\s+subnet|this\s+network|find\s+(?:for\s+me|me)|discover)\b/ix
1151
+ )
1152
+ return :recon_act if live || (req.match?(LIVE_RECON_RX) && !req.match?(HOWTO_RX))
1153
+ return :howto if req.match?(HOWTO_RX)
1154
+ # Interrogative documentation without "how to"
1155
+ if req.match?(/\b(what\s+(?:flags?|options?|switches?)|usage|syntax)\b/i) &&
1156
+ !req.match?(/\b(run|execute|scan|find|discover)\b/i)
1157
+ return :howto
1158
+ end
1159
+
1160
+ :act
1161
+ rescue StandardError
1162
+ :act
1163
+ end
1164
+
1165
+ public_class_method def self.recon_authorized?(opts = {})
1166
+ req = opts[:request].to_s
1167
+ return true if req.match?(AUTH_SCOPE_RX)
1168
+
1169
+ # Explicit engage flag from operator / REPL
1170
+ v = (PWN::Env.dig(:ai, :agent, :recon_authorized) if defined?(PWN::Env))
1171
+ return true if v == true || v.to_s =~ /\A(1|true|yes|on)\z/i
1172
+
1173
+ false
1174
+ rescue StandardError
1175
+ false
1176
+ end
1177
+
1178
+ # Pure how-to: one text-only chat (no tools, no plan_first, no task recon).
1179
+ private_class_method def self.answer_howto(opts = {})
1180
+ request = opts[:request].to_s
1181
+ session_id = opts[:session_id]
1182
+ system_role_content = opts[:system_role_content].to_s
1183
+ engine = active_engine
1184
+ mod_name = ENGINE_MODS[engine]
1185
+ raise "ERROR: Unsupported AI engine for agent loop: #{engine}" unless mod_name
1186
+
1187
+ mod = Object.const_get(mod_name)
1188
+ howto_sys = <<~SYS
1189
+ #{system_role_content}
1190
+
1191
+ INTENT: HOW-TO / DOCUMENTATION (this turn only)
1192
+ The user wants a concise explanation of how to use a tool or technique.
1193
+ Answer in plain US English with example command lines only.
1194
+ Do NOT call tools. Do NOT plan multi-step work. Do NOT run scans,
1195
+ probes, rubocop, rake, or host verification. Do NOT invent task
1196
+ traces or internal planner monologue. Do NOT claim you ran anything.
1197
+ If the topic is network discovery, give safe lab examples and note
1198
+ that live sweeps need explicit in-scope authorization.
1199
+ SYS
1200
+
1201
+ txt =
1202
+ if mod.respond_to?(:chat)
1203
+ r = mod.chat(
1204
+ request: request,
1205
+ system_role_content: howto_sys,
1206
+ spinner: true
1207
+ )
1208
+ if r.is_a?(Hash)
1209
+ (r.dig(:choices, -1, :content) || r.dig(:choices, -1, :text) || r[:content]).to_s
1210
+ else
1211
+ r.to_s
1212
+ end
1213
+ else
1214
+ # chat_with_tools without tools
1215
+ messages = [
1216
+ { role: 'system', content: howto_sys },
1217
+ { role: 'user', content: request }
1218
+ ]
1219
+ msg = call_engine(messages: messages, tools: nil)
1220
+ msg.is_a?(Hash) ? msg[:content].to_s : msg.to_s
1221
+ end
1222
+
1223
+ txt = txt.to_s.strip
1224
+ if txt.empty?
1225
+ txt = <<~FALLBACK
1226
+ Example hping3 ICMP ping sweep (lab / authorized targets only):
1227
+
1228
+ # Needs root/CAP_NET_RAW for raw sockets
1229
+ sudo hping3 -1 --flood -c 1 192.168.1.x
1230
+ # Or loop a /24 carefully (slow rate to avoid DoS):
1231
+ for i in $(seq 1 254); do
1232
+ sudo hping3 -1 -c 1 -N $i 192.168.1.$i 2>/dev/null | grep -q "icmp" && echo "up 192.168.1.$i"
1233
+ done
1234
+
1235
+ Prefer intentional CIDR targeting over blind sweeps. Live host
1236
+ discovery without explicit engagement/scope authorization is out
1237
+ of bounds for this agent.
1238
+ FALLBACK
1239
+ end
1240
+
1241
+ append_session(session_id: session_id, role: 'user', content: request)
1242
+ append_session(session_id: session_id, role: 'assistant', content: txt)
1243
+ if defined?(Learning) && should_auto_introspect?(local: local_engine?, turn_fails: {}, iter: 0)
1244
+ Learning.auto_introspect(
1245
+ session_id: session_id,
1246
+ request: request,
1247
+ final: txt,
1248
+ predicted: 0.9,
1249
+ plan: ['Explain tool usage without live recon'],
1250
+ ts_state: nil
1251
+ )
1252
+ end
1253
+ txt
1254
+ rescue StandardError => e
1255
+ warn "[pwn-ai/loop] answer_howto swallowed: #{e.class}: #{e.message}"
1256
+ "Could not produce a how-to answer (#{e.class}: #{e.message}). Retry with a frontier engine or ask for a specific flag/example."
1257
+ end
1258
+
1259
+ private_class_method def self.refuse_unauthorized_recon(opts = {})
1260
+ request = opts[:request].to_s
1261
+ session_id = opts[:session_id]
1262
+ txt = <<~REFUSE
1263
+ I will not run a live subnet sweep or raw-socket host discovery from this agent without explicit in-scope authorization.
1264
+
1265
+ Your request looks like active recon on "this" network/subnet. To proceed, restate with engagement language such as:
1266
+ - "authorized engagement" / "in-scope" / "lab only" / "I own this network"
1267
+ - or set PWN::Env[:ai][:agent][:recon_authorized] = true for this session
1268
+
1269
+ If you only wanted the command syntax, ask: how to do a ping sweep of a subnet using hping3?
1270
+ REFUSE
1271
+ append_session(session_id: session_id, role: 'user', content: request)
1272
+ append_session(session_id: session_id, role: 'assistant', content: txt)
1273
+ txt
1274
+ end
1275
+
829
1276
  # Supported Method Parameters::
830
1277
  # final = PWN::AI::Agent::Loop.run(
831
1278
  # request: 'required - what the human typed',
@@ -842,13 +1289,27 @@ module PWN
842
1289
  # Live coalesced "what am I doing" lines for the TUI (not a model tool).
843
1290
  ts_state = (TaskSummarizer.fresh(request: request) if defined?(TaskSummarizer) && TaskSummarizer.enabled? && Thread.current[:pwn_reflect_depth].to_i.zero?)
844
1291
  engine = active_engine
845
- local = engine == :ollama
1292
+ local = local_engine?(engine: engine)
846
1293
  system_role_content = opts[:system_role_content] ||= PWN::AI::Agent::PromptBuilder.build(session_id: session_id, request: request)
847
1294
 
848
1295
  Registry.discover
849
1296
  expose_current_session(session_id: session_id)
850
1297
  Mistakes.check_user_correction(request: request, session_id: session_id) if defined?(Mistakes)
851
1298
 
1299
+ intent = request_intent(request: request)
1300
+ Thread.current[:pwn_request_intent] = intent
1301
+ Thread.current[:pwn_recon_authorized] = recon_authorized?(request: request)
1302
+ # How-to: never enter plan_first / task recon / tool thrash (ollama/openwebui).
1303
+ if intent == :howto && opts[:force_tools] != true
1304
+ return answer_howto(
1305
+ request: request,
1306
+ session_id: session_id,
1307
+ system_role_content: system_role_content
1308
+ )
1309
+ end
1310
+ # Live recon without authorization: refuse rather than probe the LAN.
1311
+ return refuse_unauthorized_recon(request: request, session_id: session_id) if intent == :recon_act && !recon_authorized?(request: request) && opts[:force_tools] != true
1312
+
852
1313
  # Initial tool pool from the user request (bootstrap only). After
853
1314
  # TaskSummarizer.emit_plan! we re-rank using English tangible tasks
854
1315
  # so generated tasks — not the bare request — drive which tools
@@ -875,7 +1336,8 @@ module PWN
875
1336
  Thread.current[:pwn_plan_predicted] = nil
876
1337
  cal_state = calibration_state
877
1338
  force_plan = cal_state[:force_plan]
878
- if (force_plan || agent_flag(key: :plan_first, default: local) || budget_exhaustion_hot?) && !Array(tools).empty?
1339
+ skip_plan = (intent == :howto)
1340
+ if !skip_plan && (force_plan || agent_flag(key: :plan_first, default: local) || budget_exhaustion_hot?) && !Array(tools).empty?
879
1341
  predicted = plan_first(messages: messages, request: request, ts_state: ts_state)
880
1342
  # P22 — prefer explicit return; fall back to thread stash
881
1343
  predicted = Thread.current[:pwn_plan_predicted] if predicted.nil?
@@ -889,7 +1351,7 @@ module PWN
889
1351
  inject_task_focus!(messages: messages, state: ts_state, force: true)
890
1352
  end
891
1353
  if budget_exhaustion_hot?
892
- hot_hint = if active_engine == :ollama
1354
+ hot_hint = if local_engine?
893
1355
  '[pwn-ai/p17] Budget-exhaustion is the top open failure on this host. ' \
894
1356
  'Prefer the SHORTEST plan that finishes the ask (≤3 tool calls). ' \
895
1357
  'Emit a final answer as soon as you have evidence — do not explore.'
@@ -949,7 +1411,7 @@ module PWN
949
1411
  # Plan-faithful: delay the text-only strip when a plan is executing
950
1412
  # cleanly. Remote hot allows longer plans (runway 25); local hot
951
1413
  # still favors short plans under the 8-iter cap.
952
- plan_step_limit = active_engine == :ollama ? 3 : 12
1414
+ plan_step_limit = local_engine? ? 3 : 12
953
1415
  plan_faithful = hot && plan_steps.positive? && plan_steps <= plan_step_limit &&
954
1416
  turn_fails['empty_final'].to_i.zero? &&
955
1417
  turn_fails.values.sum < 2
@@ -957,7 +1419,7 @@ module PWN
957
1419
  if plan_faithful
958
1420
  1
959
1421
  else
960
- (active_engine == :ollama ? 3 : 2)
1422
+ (local_engine? ? 3 : 2)
961
1423
  end
962
1424
  else
963
1425
  1
@@ -984,6 +1446,20 @@ module PWN
984
1446
  calls = Array(msg[:tool_calls])
985
1447
  text = msg[:content].to_s
986
1448
 
1449
+ # Belt-and-suspenders: plain-text shell(...) / tool forms from local
1450
+ # models under weak TEMPLATE {{ .Prompt }} become real tool_calls.
1451
+ if calls.empty? && !text.strip.empty? && !last_iter &&
1452
+ defined?(Dispatch) && Dispatch.respond_to?(:tool_calls_from_text)
1453
+ coerced = Dispatch.tool_calls_from_text(text: text)
1454
+ if coerced.any?
1455
+ wired = coerced.map { |tc| openai_wire_tool_call(tool_call: tc) }
1456
+ msg = msg.merge(tool_calls: wired, content: nil, _text_tool_coerced: true)
1457
+ calls = wired
1458
+ text = ''
1459
+ warn "[pwn-ai/loop] coerced #{wired.length} text tool call(s) on iter=#{i}" if local
1460
+ end
1461
+ end
1462
+
987
1463
  # Empty-final guard (local/thinking models): Ollama sometimes
988
1464
  # returns done_reason=stop with eval_count<=1, empty content, no
989
1465
  # tool_calls — historically surface as a blank TUI reply. Do NOT
@@ -1005,15 +1481,16 @@ module PWN
1005
1481
 
1006
1482
  if calls.empty?
1007
1483
  # P28 — refuse polite mid-goal handoffs so multi-step tasks stay autonomous.
1008
- if incomplete_final?(text: text, last_iter: last_iter) && turn_fails['incomplete_final'].to_i < 2
1484
+ if incomplete_final?(text: text, last_iter: last_iter) && turn_fails['incomplete_final'].to_i < 4
1009
1485
  turn_fails['incomplete_final'] += 1
1010
1486
  warn "[pwn-ai/loop] incomplete final on iter=#{i}; continuing autonomously"
1011
1487
  messages << {
1012
1488
  role: 'user',
1013
- content: '[pwn-ai/p28] That reply handed control back before the goal was done. ' \
1014
- 'Do NOT ask the user to confirm the next step. Continue with the ' \
1015
- 'necessary tool calls now and finish the goal autonomously. Only ' \
1016
- 'emit a final answer when the request is complete or truly blocked.'
1489
+ content: '[pwn-ai/p28] That reply was incomplete (handoff or narrated next step). ' \
1490
+ 'Do NOT monologue about what you will try. Do NOT ask the user to ' \
1491
+ 'confirm. Emit NATIVE tool_calls NOW (e.g. shell with a concrete ' \
1492
+ 'command). Never print shell(...) as plain text. Only emit a final ' \
1493
+ 'answer when the request is complete or truly blocked with evidence.'
1017
1494
  }
1018
1495
  next
1019
1496
  end
@@ -1190,8 +1667,12 @@ module PWN
1190
1667
  Supported engines: #{ENGINE_MODS.keys.join(', ')}
1191
1668
  Set PWN::Env[:ai][:active] to choose; PWN::Env[:ai][:agent][:max_iters] to bound.
1192
1669
 
1670
+ Intent routing (all engines; critical for ollama/openwebui):
1671
+ how-to / usage questions → text-only explanation (no tools, no plan_first)
1672
+ live subnet sweeps without scope language → refuse
1673
+ :recon_authorized - Boolean session flag to allow raw-socket / sweep tools
1193
1674
  Local-model scaffolding (PWN::Env[:ai][:agent]):
1194
- :plan_first - Boolean, plan-then-act pre-pass (default: engine == :ollama)
1675
+ :plan_first - Boolean, plan-then-act pre-pass (default: local engine :ollama/:openwebui)
1195
1676
  :tool_router - Boolean/nil, slim Registry.definitions (nil=auto on for ollama)
1196
1677
  :escalation_persona - Swarm persona name for frontier corrective hints when stuck
1197
1678
  :critic - S3 constitutional critic before every final (Boolean)