pwn 0.5.668 → 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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CONTRIBUTING.md +15 -0
  3. data/README.md +4 -4
  4. data/documentation/AI-Integration.md +5 -3
  5. data/documentation/Agent-Tool-Registry.md +18 -14
  6. data/documentation/Configuration.md +2 -0
  7. data/documentation/Contributing.md +13 -0
  8. data/documentation/Diagrams.md +1 -1
  9. data/documentation/Home.md +4 -4
  10. data/documentation/How-PWN-Works.md +6 -6
  11. data/documentation/Persistence.md +17 -0
  12. data/documentation/Reinforcement-Learning.md +39 -39
  13. data/documentation/SDR.md +1 -1
  14. data/documentation/Sessions.md +1 -0
  15. data/documentation/Skills-Memory-Learning.md +2 -0
  16. data/documentation/What-is-PWN.md +3 -3
  17. data/documentation/diagrams/agent-tool-registry.svg +43 -43
  18. data/documentation/diagrams/ai-integration-tool-calling.svg +112 -85
  19. data/documentation/diagrams/dot/agent-tool-registry.dot +4 -4
  20. data/documentation/diagrams/dot/ai-integration-tool-calling.dot +7 -3
  21. data/documentation/diagrams/dot/memory-skills-detailed.dot +2 -2
  22. data/documentation/diagrams/dot/overall-pwn-architecture.dot +6 -4
  23. data/documentation/diagrams/dot/persistence-filesystem.dot +1 -1
  24. data/documentation/diagrams/dot/sdr-radio-flow.dot +1 -1
  25. data/documentation/diagrams/memory-skills-detailed.svg +182 -182
  26. data/documentation/diagrams/overall-pwn-architecture.svg +154 -146
  27. data/documentation/diagrams/persistence-filesystem.svg +6 -6
  28. data/documentation/diagrams/sdr-radio-flow.svg +1 -1
  29. data/documentation/pwn-ai-Agent.md +29 -13
  30. data/lib/pwn/ai/agent/loop.rb +189 -1
  31. data/lib/pwn/ai/agent/prompt_builder.rb +24 -5
  32. data/lib/pwn/ai/agent/task_summarizer.rb +40 -3
  33. data/lib/pwn/ai/agent/tools/shell.rb +32 -0
  34. data/lib/pwn/memory_index.rb +17 -2
  35. data/lib/pwn/plugins/repl.rb +36 -7
  36. data/lib/pwn/version.rb +1 -1
  37. data/spec/lib/pwn/ai/agent/loop_spec.rb +31 -0
  38. data/spec/lib/pwn/ai/agent/task_summarizer_spec.rb +10 -0
  39. data/spec/lib/pwn/ai/agent/tools/shell_spec.rb +21 -0
  40. data/spec/lib/pwn/plugins/repl_pwn_vault_spec.rb +134 -0
  41. data/third_party/pwn_rdoc.jsonl +5 -0
  42. metadata +2 -1
@@ -254,16 +254,16 @@
254
254
  <!-- cur -->
255
255
  <g id="node18" class="node">
256
256
  <title>cur</title>
257
- <path fill="#fcd34d" stroke="#334155" stroke-width="1.3" d="M408.62,-238C408.62,-240.21 374.45,-242 332.38,-242 290.3,-242 256.12,-240.21 256.12,-238 256.12,-238 256.12,-202 256.12,-202 256.12,-199.79 290.3,-198 332.38,-198 374.45,-198 408.62,-199.79 408.62,-202 408.62,-202 408.62,-238 408.62,-238"/>
258
- <path fill="none" stroke="#334155" stroke-width="1.3" d="M408.62,-238C408.62,-235.79 374.45,-234 332.38,-234 290.3,-234 256.12,-235.79 256.12,-238"/>
259
- <text xml:space="preserve" text-anchor="middle" x="332.38" y="-222.5" font-family="sans-Serif" font-size="10.00" fill="#0f172a">curriculum/</text>
260
- <text xml:space="preserve" text-anchor="middle" x="332.38" y="-210.5" font-family="sans-Serif" font-size="10.00" fill="#0f172a">self&#45;play reproducers · A/B eval</text>
257
+ <path fill="#fcd34d" stroke="#334155" stroke-width="1.3" d="M413.5,-238C413.5,-240.21 377.14,-242 332.38,-242 287.61,-242 251.25,-240.21 251.25,-238 251.25,-238 251.25,-202 251.25,-202 251.25,-199.79 287.61,-198 332.38,-198 377.14,-198 413.5,-199.79 413.5,-202 413.5,-202 413.5,-238 413.5,-238"/>
258
+ <path fill="none" stroke="#334155" stroke-width="1.3" d="M413.5,-238C413.5,-235.79 377.14,-234 332.38,-234 287.61,-234 251.25,-235.79 251.25,-238"/>
259
+ <text xml:space="preserve" text-anchor="middle" x="332.38" y="-222.5" font-family="sans-Serif" font-size="10.00" fill="#0f172a">curriculum/ + curriculum_kpi.jsonl</text>
260
+ <text xml:space="preserve" text-anchor="middle" x="332.38" y="-210.5" font-family="sans-Serif" font-size="10.00" fill="#0f172a">self&#45;play · A/B eval · KPI trend</text>
261
261
  </g>
262
262
  <!-- Root&#45;&gt;cur -->
263
263
  <g id="edge17" class="edge">
264
264
  <title>Root&#45;&gt;cur</title>
265
- <path fill="none" stroke="#94a3b8" stroke-width="1.1" d="M105.59,-671.76C106.02,-605.83 116.42,-377.3 235.5,-253 239.43,-248.9 243.89,-245.31 248.68,-242.17"/>
266
- <polygon fill="#94a3b8" stroke="#94a3b8" stroke-width="1.1" points="249.72,-244.4 254.49,-238.72 247.22,-240.18 249.72,-244.4"/>
265
+ <path fill="none" stroke="#94a3b8" stroke-width="1.1" d="M105.59,-671.76C106.02,-605.83 116.42,-377.3 235.5,-253 238.03,-250.35 240.79,-247.92 243.72,-245.68"/>
266
+ <polygon fill="#94a3b8" stroke="#94a3b8" stroke-width="1.1" points="245.06,-247.73 249.45,-241.75 242.29,-243.69 245.06,-247.73"/>
267
267
  </g>
268
268
  <!-- ftn -->
269
269
  <g id="node19" class="node">
@@ -10,7 +10,7 @@
10
10
  <title>PWN_SDR</title>
11
11
  <polygon fill="#0f172a" stroke="none" points="-43.2,43.2 -43.2,-821.05 1748.95,-821.05 1748.95,43.2 -43.2,43.2"/>
12
12
  <text xml:space="preserve" text-anchor="start" x="614.38" y="-755.85" font-family="sans-Serif" font-weight="bold" font-size="20.00" fill="#e2e8f0">PWN::SDR &#45; Software&#45;Defined Radio &amp; RF Hacking</text>
13
- <text xml:space="preserve" text-anchor="start" x="469.62" y="-744.4" font-family="sans-Serif" font-size="11.00" fill="#94a3b8">GQRX · FlipperZero · RFIDler · FrequencyAllocation · Decoder::* (20) · Base.run_iq (true&#45;air) · FFI::{Volk,Liquid,FFTW,RTLSdr,HackRF,AdalmPluto,SoapySDR}</text>
13
+ <text xml:space="preserve" text-anchor="start" x="440.75" y="-744.4" font-family="sans-Serif" font-size="11.00" fill="#94a3b8">GQRX · FlipperZero · RFIDler · FrequencyAllocation · Decoder::* (20+Base/DSP) · Base.run_iq (true&#45;air) · FFI::{Volk,Liquid,FFTW,RTLSdr,HackRF,AdalmPluto,SoapySDR}</text>
14
14
  <g id="clust1" class="cluster">
15
15
  <title>cluster_hw</title>
16
16
  <path fill="#022c22" stroke="#047857" d="M223.5,-558C223.5,-558 487,-558 487,-558 493,-558 499,-564 499,-570 499,-570 499,-697 499,-697 499,-703 493,-709 487,-709 487,-709 223.5,-709 223.5,-709 217.5,-709 211.5,-703 211.5,-697 211.5,-697 211.5,-570 211.5,-570 211.5,-564 217.5,-558 223.5,-558"/>
@@ -90,7 +90,7 @@ $ pwn --ai "run bin/pwn_sast against ./src and push findings to DefectDojo"
90
90
 
91
91
  ## What the agent can call
92
92
 
93
- 12 toolsets · **78 tools** - full table at
93
+ 12 toolsets · **82 tools** - full table at
94
94
  [Agent Tool Registry](Agent-Tool-Registry.md).
95
95
 
96
96
  The two that matter most:
@@ -118,7 +118,7 @@ full `Loop.run` under a persona overlay) that share a JSONL bus. See
118
118
  | Surface | When | Content |
119
119
  |---|---|---|
120
120
  | `emit_plan!` | User submit | **Full** goal + ordered plain-English tangible tasks (each may need many tools) |
121
- | `about_to` | Before each tool batch | **Primary:** `task k/n: <english>` - **secondary:** `via shell×2 (search)` (not raw argv) |
121
+ | `about_to` | Before each tool batch | **Primary:** `task k/n: <english>` - **secondary:** `via shell×2 (search)` (not raw argv) |
122
122
  | `plan_context` / `active_task_prompt` | Into Loop messages | Same English tasks steer tool choice (not TUI-only) |
123
123
  | `record!` | After each tool | Advances `plan_idx`; emits English advancement brief when the index moves; verbose progress only if `task_summary_verbose` |
124
124
  | `flush!` | End of turn | Optional closing brief with active `task k/n` |
@@ -131,7 +131,7 @@ full `Loop.run` under a persona overlay) that share a JSONL bus. See
131
131
  - Advancement needs a PRM +1 streak or a clear phase shift after tools on the active task (not a blind every-3-tools hop).
132
132
  - REPL contract: `on_tool.call('task', full_summary_text, '')` - result empty, no truncation.
133
133
 
134
- **Budget pressure:** when unresolved `agent_loop` / `assistant_answer` budget-exhaustion fingerprints dominate, `Loop.budget_exhaustion_hot?` tightens the live turn (stricter `max_iters` on local engines than remote), forces a text-only tail, skips counterfactual forks, and still flushes task state + Learning on the exhaust path.
134
+ **Long-run pressure:** when recent turns keep hitting the iteration ceiling, the agent tightens the rest of the turn: lower `max_iters` (stricter on local engines than remote), a text-only finish, and no extra counterfactual forks. Task state and Learning still flush on the way out so the run ends with a real answer instead of thrashing.
135
135
 
136
136
  ![TaskSummarizer](diagrams/task-summarizer.svg)
137
137
 
@@ -157,18 +157,17 @@ max_iters: 25 # budget pressure may lower the effective cap (s
157
157
  - Run `mistakes_list` before retrying something that failed last session -
158
158
  the fix may already be recorded.
159
159
  - `ai.agent.tool_router: true` + `ai.agent.plan_first: true` when running on
160
- a local model - dramatically cuts mis-routing.
160
+ a local model - that cuts mis-routing a lot.
161
161
  - Set `ai.reflect_engine:` to a frontier provider so lessons written to
162
- `~/.pwn/memory.json` are high-signal even when the *executing* engine is
162
+ `~/.pwn/memory.json` stay high-signal even when the executing engine is
163
163
  local.
164
- - `PWN::AI::Agent::Learning.export_finetune` + `Reward.export_dpo` turn every
165
- successful session and every preference pair into supervised / DPO
166
- datasets under `~/.pwn/finetune/` - `Curriculum.train_and_gate` then
167
- LoRA-tunes the local model and promotes only under **gate v2** (resolved
168
- margin + mean judge + frozen smoke set). Preference pairs prefer trajectory
169
- geometry: revised answers and winning traces, not fix-commentary prose.
170
- `scrub_preferences` and the export filter enforce that; practice lands
171
- winning traces.
164
+ - `PWN::AI::Agent::Learning.export_finetune` and `Reward.export_dpo` turn
165
+ successful sessions and preference pairs into supervised / preference
166
+ datasets under `~/.pwn/finetune/`. `Curriculum.train_and_gate` can then
167
+ fine-tune a local model and promote only when resolved-mistake margin,
168
+ mean judge score, and a frozen smoke set all look healthy. Preference
169
+ pairs should be real answer revisions and winning traces, not fix-commentary
170
+ prose. `scrub_preferences` and the export filter enforce that.
172
171
  See [Reinforcement Learning](Reinforcement-Learning.md).
173
172
 
174
173
  ## RL feature flags (`PWN::Env[:ai][:agent]`)
@@ -189,3 +188,20 @@ Full detail: [Reinforcement Learning](Reinforcement-Learning.md).
189
188
  [Extrospection](Extrospection.md) · [Swarm](Swarm.md) · [Cron](Cron.md)
190
189
 
191
190
  [← Home](Home.md)
191
+
192
+ ## Intent routing (how-to vs act vs recon)
193
+
194
+ On local engines (`ollama`, `openwebui`), plain "how do I..." questions used to
195
+ kick off multi-step host probes and planner noise. Each user turn is classified
196
+ first:
197
+
198
+ | Intent | Example | Behavior |
199
+ |--------|---------|----------|
200
+ | How-to | "how to do a ping sweep of a subnet using hping3?" | Short explanation with example commands only. No tools and no live scan plan. |
201
+ | Live recon | "using hping3 what live hosts can you find in this subnet?" | Needs clear in-scope / authorized engagement wording, or set `ai.agent.recon_authorized=true`. Otherwise the agent refuses and points you at the how-to form. |
202
+ | Act | "refactor Loop.run and run rubocop" | Normal multi-step agent work with tools. |
203
+
204
+ The `shell` tool also blocks hping3 / nmap-style sweep commands when recon is
205
+ not authorized. On how-to asks, memory SOPs about repo rubocop/rake hygiene are
206
+ kept out of the prompt so the model does not pivot into unrelated verification.
207
+
@@ -1104,6 +1104,175 @@ module PWN
1104
1104
  nil
1105
1105
  end
1106
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
+
1107
1276
  # Supported Method Parameters::
1108
1277
  # final = PWN::AI::Agent::Loop.run(
1109
1278
  # request: 'required - what the human typed',
@@ -1127,6 +1296,20 @@ module PWN
1127
1296
  expose_current_session(session_id: session_id)
1128
1297
  Mistakes.check_user_correction(request: request, session_id: session_id) if defined?(Mistakes)
1129
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
+
1130
1313
  # Initial tool pool from the user request (bootstrap only). After
1131
1314
  # TaskSummarizer.emit_plan! we re-rank using English tangible tasks
1132
1315
  # so generated tasks — not the bare request — drive which tools
@@ -1153,7 +1336,8 @@ module PWN
1153
1336
  Thread.current[:pwn_plan_predicted] = nil
1154
1337
  cal_state = calibration_state
1155
1338
  force_plan = cal_state[:force_plan]
1156
- 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?
1157
1341
  predicted = plan_first(messages: messages, request: request, ts_state: ts_state)
1158
1342
  # P22 — prefer explicit return; fall back to thread stash
1159
1343
  predicted = Thread.current[:pwn_plan_predicted] if predicted.nil?
@@ -1483,6 +1667,10 @@ module PWN
1483
1667
  Supported engines: #{ENGINE_MODS.keys.join(', ')}
1484
1668
  Set PWN::Env[:ai][:active] to choose; PWN::Env[:ai][:agent][:max_iters] to bound.
1485
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
1486
1674
  Local-model scaffolding (PWN::Env[:ai][:agent]):
1487
1675
  :plan_first - Boolean, plan-then-act pre-pass (default: local engine :ollama/:openwebui)
1488
1676
  :tool_router - Boolean/nil, slim Registry.definitions (nil=auto on for ollama)
@@ -70,6 +70,18 @@ module PWN
70
70
  irreversible destructive action, or missing external decision is
71
71
  strictly required. Partial progress reports without completing the
72
72
  goal are incorrect behavior.
73
+
74
+ INTENT AND SCOPE
75
+ Match effort to the user ask. Pure how-to / syntax / usage questions
76
+ get a concise explanation with example commands only — no tool calls,
77
+ no multi-step recon plan, no live probes, no rubocop/rake/docs side
78
+ quests, and no invented planner or verification monologue.
79
+ Do not treat process_sop_* or operator_pref_* memory about code
80
+ hygiene as the current user goal unless they asked to change code.
81
+ Live subnet sweeps, raw-socket discovery (hping3/nmap -sn mass
82
+ probes, etc.) require explicit in-scope / authorized engagement
83
+ language from the user. Without it, refuse the live scan and offer
84
+ the command syntax instead.
73
85
  PROMPT
74
86
  end
75
87
 
@@ -119,11 +131,18 @@ module PWN
119
131
 
120
132
  limit = opts[:limit] || 25
121
133
  req = opts[:request]
122
- ctx = if req && defined?(PWN::MemoryIndex) && PWN::MemoryIndex.available?
123
- PWN::MemoryIndex.to_context(query: req, limit: limit)
124
- else
125
- PWN::Memory.to_context(limit: limit)
126
- end
134
+ # How-to / non-code asks: do not let code-hygiene SOPs dominate local context.
135
+ drop_hygiene = req.to_s.match?(/\b(how\s+to|how\s+do\s+i|syntax|usage|hping|nmap|ping\s+sweep|live\s+hosts?)\b/i) &&
136
+ !req.to_s.match?(%r{\b(rubocop|rake|rspec|/opt/pwn|refactor|documentation|commit)\b}i)
137
+ ctx = if req && defined?(PWN::MemoryIndex) && PWN::MemoryIndex.available?
138
+ PWN::MemoryIndex.to_context(query: req, limit: limit, drop_hygiene_sops: drop_hygiene)
139
+ else
140
+ PWN::Memory.to_context(limit: limit)
141
+ end
142
+ if drop_hygiene && ctx.to_s.match?(/process_sop_.*rubocop|operator_pref_docs_after_rubocop|docs_after_rubocop_rake/i)
143
+ # Fallback filter when MemoryIndex unavailable or still returns SOPs
144
+ ctx = ctx.to_s.lines.grep_v(/process_sop_.*(?:rubocop|rake|docs_after)|operator_pref_docs_after_rubocop|code_hygiene/i).join
145
+ end
127
146
  ctx.to_s.strip.empty? ? '' : "MEMORY#{ctx}\n\n"
128
147
  rescue StandardError
129
148
  ''
@@ -51,12 +51,19 @@ module PWN
51
51
  should perform. Rules:
52
52
  - 2..12 tasks. Each task is one coherent unit of work (may need many tools).
53
53
  - Plain US English. Imperative mood. No tool names, paths, or shell commands.
54
+ - Match effort to intent: pure how-to / usage / syntax questions get 1..2
55
+ documentation tasks only (explain usage, present examples). Do NOT invent
56
+ live recon, host discovery, rubocop, rake, or code-verification steps for
57
+ those. Only plan discovery/recon when the user asked to scan/find live hosts.
54
58
  - Cover discovery/recon, the core work, verification, and the requested
55
- deliverable/format when relevant.
59
+ deliverable/format ONLY when relevant to THIS request.
56
60
  - Tailor steps to THIS request only — do not reuse a canned domain script.
57
61
  - Last task should verify or present the final result when that fits.
62
+ - Never append unrelated repo hygiene (rubocop/rake/docs) unless the user
63
+ asked to change code under /opt/pwn.
58
64
  - Output ONLY a JSON array of strings. No markdown, no prose, no keys.
59
65
  Example: ["determine the local IPv4 subnet","find live hosts on that subnet","present live hosts as JSON"]
66
+ How-to example: ["Explain hping3 ping-sweep syntax with safe lab examples","Present the commands clearly"]
60
67
  SYS
61
68
 
62
69
  public_class_method def self.enabled?
@@ -319,11 +326,33 @@ module PWN
319
326
  goal.empty? ? [] : normalize_task_list(tasks: ["Carry out: #{goal}"], goal: goal)
320
327
  end
321
328
 
329
+ private_class_method def self.howto_goal?(opts = {})
330
+ goal = opts[:goal].to_s
331
+ return true if defined?(PWN::AI::Agent::Loop) &&
332
+ PWN::AI::Agent::Loop.respond_to?(:request_intent) &&
333
+ PWN::AI::Agent::Loop.request_intent(request: goal) == :howto
334
+
335
+ goal.match?(/\b(how\s+to|how\s+do\s+i|how\s+can\s+i|syntax|usage of|explain how|show me how)\b/i)
336
+ rescue StandardError
337
+ false
338
+ end
339
+
322
340
  private_class_method def self.normalize_task_list(opts = {})
323
341
  goal = opts[:goal]
324
342
  list = Array(opts[:tasks]).map { |t| t.to_s.gsub(/\s+/, ' ').strip }.reject(&:empty?).uniq
325
343
  list = ["Carry out: #{goal}"] if list.empty?
326
- # Ensure a verify/close step when the model omitted one.
344
+ # Strip contaminated repo-hygiene tasks on non-code requests.
345
+ unless goal.to_s.match?(%r{\b(rubocop|rake|rspec|/opt/pwn|refactor|patch|commit|documentation)\b}i)
346
+ list.reject! { |t| t.match?(/\b(rubocop|rvmsudo\s+rake|bundle\s+exec\s+rake|fix rake|lint offenses)\b/i) }
347
+ list = ["Carry out: #{goal}"] if list.empty?
348
+ end
349
+ # How-to: keep explanation-only plan; never force recon/verify thrash.
350
+ if howto_goal?(goal: goal)
351
+ list = list.first(2)
352
+ list = ['Explain the requested tool usage with concrete examples', 'Present the answer clearly'] if list.empty?
353
+ return list.first(MAX_PLAN_TASKS)
354
+ end
355
+ # Ensure a verify/close step when the model omitted one (act/recon only).
327
356
  list << 'Verify the result and report completion' unless list.last.to_s.match?(/verif|test|confirm|rubocop|rake|accept|done|close|summar|json|yaml|table|present|format|convert|report completion|report results/i)
328
357
  list.first(MAX_PLAN_TASKS)
329
358
  end
@@ -584,6 +613,12 @@ module PWN
584
613
  return tasks
585
614
  end
586
615
 
616
+ if howto_goal?(goal: goal_text)
617
+ tasks << "Explain how to do: #{truncate_goal(goal: goal_text)}"
618
+ tasks << 'Present clear example commands only (no live probes)'
619
+ return tasks
620
+ end
621
+
587
622
  tasks << "Understand the request: #{truncate_goal(goal: goal_text)}"
588
623
  tasks << "Carry out the core work for: #{truncate_goal(goal: goal_text)}"
589
624
 
@@ -594,7 +629,9 @@ module PWN
594
629
  tasks << 'Present the final results in the requested format'
595
630
  end
596
631
 
597
- tasks << 'Run specs, rubocop, and/or rake to verify' if goal_lc.match?(/\b(test|spec|rubocop|rake|lint|verify|accept)\b/)
632
+ # Only when the user actually asked about tests/lint — not bare "verify".
633
+ tasks << 'Run specs, rubocop, and/or rake to verify' if goal_lc.match?(/\b(test|spec|rubocop|rake|lint)\b/) &&
634
+ goal_lc.match?(%r{\b(/opt/pwn|code|patch|refactor|commit)\b})
598
635
 
599
636
  tasks
600
637
  rescue StandardError
@@ -46,6 +46,38 @@ PWN::AI::Agent::Registry.register(
46
46
  timeout = (args[:timeout] || 120).to_i
47
47
  raise ArgumentError, 'command is required' if cmd.empty?
48
48
 
49
+ # Guard: raw-socket / subnet discovery without explicit engagement auth.
50
+ # Intent routing should short-circuit most of these; this is defense-in-depth
51
+ # when a local model still emits hping3/nmap sweeps mid-loop.
52
+ recon_blocked = begin
53
+ auth = Thread.current[:pwn_recon_authorized] == true
54
+ unless auth
55
+ v = (PWN::Env.dig(:ai, :agent, :recon_authorized) if defined?(PWN::Env))
56
+ auth = v == true || v.to_s =~ /\A(1|true|yes|on)\z/i
57
+ end
58
+ !auth && cmd.match?(
59
+ %r{
60
+ (?:^|[;&|\s])(?:sudo\s+)?hping3?(?:\s|$).*(?:-1|--icmp|--flood|/[0-9]{1,2}|seq\s+|for\s+)
61
+ |(?:^|[;&|\s])(?:sudo\s+)?nmap\b[^\n]*(?:-sn|-sP|-PE|-PP|-PM)[^\n]*(?:/[0-9]{1,2}|\d+\.\d+\.\d+\.\d+)
62
+ |(?:^|[;&|\s])(?:sudo\s+)?masscan\b
63
+ |(?:^|[;&|\s])(?:sudo\s+)?nping\b
64
+ |(?:^|[;&|\s])(?:sudo\s+)?fping\b[^\n]*/
65
+ |for\s+\w+\s+in\s+\$?\(?seq[^)]*\)?[^;]*;\s*do\s[^;]*(?:hping|ping\s+-c|nmap)
66
+ }ix
67
+ )
68
+ rescue StandardError
69
+ false
70
+ end
71
+ if recon_blocked
72
+ msg = "[pwn-ai/guard] blocked unauthorized recon/sweep command. Need explicit in-scope authorization on the user request or PWN::Env[:ai][:agent][:recon_authorized]=true. Refused: #{cmd[0, 200]}"
73
+ return {
74
+ stdout: '',
75
+ stderr: msg,
76
+ exit: 126,
77
+ error: 'unauthorized_recon_blocked'
78
+ }
79
+ end
80
+
49
81
  stdout = +''
50
82
  stderr = +''
51
83
  exitstatus = nil
@@ -84,8 +84,23 @@ module PWN
84
84
  # identical so PromptBuilder needs no special-casing.
85
85
 
86
86
  public_class_method def self.to_context(opts = {})
87
- hits = recall_semantic(query: opts[:query], limit: opts[:limit] || 6)
88
- return PWN::Memory.to_context(limit: opts[:limit] || 6) if hits.empty?
87
+ limit = opts[:limit] || 6
88
+ # Fetch extra when filtering hygiene SOPs so we still fill the budget.
89
+ fetch_n = opts[:drop_hygiene_sops] ? [limit * 3, 18].max : limit
90
+ hits = recall_semantic(query: opts[:query], limit: fetch_n)
91
+ return PWN::Memory.to_context(limit: limit) if hits.empty?
92
+
93
+ if opts[:drop_hygiene_sops]
94
+ hits = hits.reject do |h|
95
+ k = h[:key].to_s
96
+ v = h[:value].to_s
97
+ k.match?(/process_sop_.*(?:rubocop|rake|docs_after|code_hygiene)|operator_pref_docs_after_rubocop/i) ||
98
+ v.match?(/\b(?:bundle exec )?rubocop\b.*\brake\b|docs_after_rubocop_rake|Documentation after code changes/i)
99
+ end.first(limit)
100
+ return PWN::Memory.to_context(limit: limit) if hits.empty?
101
+ else
102
+ hits = hits.first(limit)
103
+ end
89
104
 
90
105
  ctx = "\n\nPERSISTENT MEMORY (relevance-ranked for this request - use PWN::Memory.remember to store new ones):\n"
91
106
  hits.each do |h|
@@ -949,14 +949,15 @@ module PWN
949
949
  description 'Edit the pwn.yaml configuration file.'
950
950
 
951
951
  def process
952
- pi = pry_instance
953
952
  pwn_env_path = PWN::Env[:driver_opts][:pwn_env_path] ||= "#{Dir.home}/.pwn/pwn.yaml"
954
953
  unless File.exist?(pwn_env_path)
955
954
  puts "ERROR: pwn environment file not found: #{pwn_env_path}"
956
955
  return
957
956
  end
958
957
 
959
- pwn_dec_path = PWN::Env[:driver_opts][:pwn_dec_path] ||= "#{Dir.home}/.pwn/pwn.decryptor.yaml"
958
+ # Prefer driver_opts (set by Config.refresh_env); fall back to the
959
+ # canonical sidecar path Config uses: <pwn.yaml>.decryptor
960
+ pwn_dec_path = PWN::Env[:driver_opts][:pwn_dec_path] ||= "#{pwn_env_path}.decryptor"
960
961
  unless File.exist?(pwn_dec_path)
961
962
  puts "ERROR: pwn decryptor file not found: #{pwn_dec_path}"
962
963
  return
@@ -966,11 +967,39 @@ module PWN
966
967
  key = decryptor[:key]
967
968
  iv = decryptor[:iv]
968
969
 
969
- PWN::Plugins::Vault.edit(
970
- file: pwn_env_path,
971
- key: key,
972
- iv: iv
973
- )
970
+ # Vault.edit decrypts -> opens editor -> encrypts and only sets
971
+ # Pry.config.refresh_pwn_env = true. PWN::Config.refresh_env (which
972
+ # raises RuntimeError on invalid pwn.yaml) historically ran later in
973
+ # the PS1 hook, OUTSIDE this command - so the old rescue/retry never
974
+ # saw Config errors. Validate here: on RuntimeError print the error,
975
+ # prompt "Press Enter to Resolve", wait on $stdin.gets, then re-open
976
+ # the editor until the vault loads cleanly.
977
+ loop do
978
+ PWN::Plugins::Vault.edit(
979
+ file: pwn_env_path,
980
+ key: key,
981
+ iv: iv
982
+ )
983
+
984
+ begin
985
+ PWN::Config.refresh_env(
986
+ pwn_env_path: pwn_env_path,
987
+ pwn_dec_path: pwn_dec_path,
988
+ key: key,
989
+ iv: iv
990
+ )
991
+ break
992
+ rescue RuntimeError => e
993
+ # Keep the prior in-memory Env usable if the operator aborts
994
+ # further edits; otherwise the next PS1 tick would raise again.
995
+ Pry.config.refresh_pwn_env = false if defined?(Pry)
996
+ print "\001\e[33m\002"
997
+ puts e.message
998
+ print 'Press ENTER to resolve...'
999
+ print "\001\e[0m\002\s"
1000
+ $stdin.gets
1001
+ end
1002
+ end
974
1003
  rescue StandardError => e
975
1004
  raise e
976
1005
  end
data/lib/pwn/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PWN
4
- VERSION = '0.5.668'
4
+ VERSION = '0.5.669'
5
5
  end
@@ -145,6 +145,37 @@ describe PWN::AI::Agent::Loop do
145
145
  end
146
146
  end
147
147
 
148
+ describe 'intent routing (ollama/openwebui how-to + recon guard)' do
149
+ it 'classifies pure how-to vs live recon vs act' do
150
+ expect(described_class.request_intent(request: 'how to do a ping sweep of a subnet using hping3?')).to eq(:howto)
151
+ expect(described_class.request_intent(request: 'using hping3 what live hosts can you find in this subnet?')).to eq(:recon_act)
152
+ expect(described_class.request_intent(request: 'refactor Loop.run and run rubocop')).to eq(:act)
153
+ end
154
+
155
+ it 'recon_authorized? requires scope language or env flag' do
156
+ expect(described_class.recon_authorized?(request: 'find live hosts on this subnet')).to eq(false)
157
+ expect(described_class.recon_authorized?(request: 'authorized engagement: find live hosts on this lab subnet')).to eq(true)
158
+ allow(PWN::Env).to receive(:dig).and_call_original
159
+ allow(PWN::Env).to receive(:dig).with(:ai, :agent, :recon_authorized).and_return(true)
160
+ expect(described_class.recon_authorized?(request: 'find live hosts')).to eq(true)
161
+ end
162
+
163
+ it 'run short-circuits how-to without plan_first or tools' do
164
+ src = File.read(described_class.method(:run).source_location.first)
165
+ expect(src).to match(/request_intent/)
166
+ expect(src).to match(/answer_howto/)
167
+ expect(src).to match(/refuse_unauthorized_recon/)
168
+ expect(src).to match(/intent == :howto/)
169
+ expect(src).to match(/skip_plan/)
170
+ end
171
+
172
+ it 'answer_howto does not register shell tool use in source path' do
173
+ src = File.read(described_class.method(:run).source_location.first)
174
+ # how-to return happens before task_summary_plan!
175
+ expect(src).to match(/if intent == :howto.*?return answer_howto/m)
176
+ end
177
+ end
178
+
148
179
  describe '.openai_wire_messages' do
149
180
  it 'stringifies Hash function.arguments and drops private keys' do
150
181
  wire = described_class.openai_wire_messages(