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.
- checksums.yaml +4 -4
- data/CONTRIBUTING.md +15 -0
- data/README.md +4 -4
- data/documentation/AI-Integration.md +5 -3
- data/documentation/Agent-Tool-Registry.md +18 -14
- data/documentation/Configuration.md +2 -0
- data/documentation/Contributing.md +13 -0
- data/documentation/Diagrams.md +1 -1
- data/documentation/Home.md +4 -4
- data/documentation/How-PWN-Works.md +6 -6
- data/documentation/Persistence.md +17 -0
- data/documentation/Reinforcement-Learning.md +39 -39
- data/documentation/SDR.md +1 -1
- data/documentation/Sessions.md +1 -0
- data/documentation/Skills-Memory-Learning.md +2 -0
- data/documentation/What-is-PWN.md +3 -3
- data/documentation/diagrams/agent-tool-registry.svg +43 -43
- data/documentation/diagrams/ai-integration-tool-calling.svg +112 -85
- data/documentation/diagrams/dot/agent-tool-registry.dot +4 -4
- data/documentation/diagrams/dot/ai-integration-tool-calling.dot +7 -3
- data/documentation/diagrams/dot/memory-skills-detailed.dot +2 -2
- data/documentation/diagrams/dot/overall-pwn-architecture.dot +6 -4
- data/documentation/diagrams/dot/persistence-filesystem.dot +1 -1
- data/documentation/diagrams/dot/sdr-radio-flow.dot +1 -1
- data/documentation/diagrams/memory-skills-detailed.svg +182 -182
- data/documentation/diagrams/overall-pwn-architecture.svg +154 -146
- data/documentation/diagrams/persistence-filesystem.svg +6 -6
- data/documentation/diagrams/sdr-radio-flow.svg +1 -1
- data/documentation/pwn-ai-Agent.md +29 -13
- data/lib/pwn/ai/agent/loop.rb +189 -1
- data/lib/pwn/ai/agent/prompt_builder.rb +24 -5
- data/lib/pwn/ai/agent/task_summarizer.rb +40 -3
- data/lib/pwn/ai/agent/tools/shell.rb +32 -0
- data/lib/pwn/memory_index.rb +17 -2
- data/lib/pwn/plugins/repl.rb +36 -7
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/ai/agent/loop_spec.rb +31 -0
- data/spec/lib/pwn/ai/agent/task_summarizer_spec.rb +10 -0
- data/spec/lib/pwn/ai/agent/tools/shell_spec.rb +21 -0
- data/spec/lib/pwn/plugins/repl_pwn_vault_spec.rb +134 -0
- data/third_party/pwn_rdoc.jsonl +5 -0
- 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="
|
|
258
|
-
<path fill="none" stroke="#334155" stroke-width="1.3" d="
|
|
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
|
|
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-play
|
|
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-play · A/B eval · KPI trend</text>
|
|
261
261
|
</g>
|
|
262
262
|
<!-- Root->cur -->
|
|
263
263
|
<g id="edge17" class="edge">
|
|
264
264
|
<title>Root->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
|
|
266
|
-
<polygon fill="#94a3b8" stroke="#94a3b8" stroke-width="1.1" points="
|
|
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 - Software-Defined Radio & RF Hacking</text>
|
|
13
|
-
<text xml:space="preserve" text-anchor="start" x="
|
|
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-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 · **
|
|
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>`
|
|
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
|
-
**
|
|
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
|

|
|
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 -
|
|
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`
|
|
162
|
+
`~/.pwn/memory.json` stay high-signal even when the executing engine is
|
|
163
163
|
local.
|
|
164
|
-
- `PWN::AI::Agent::Learning.export_finetune`
|
|
165
|
-
successful
|
|
166
|
-
datasets under `~/.pwn/finetune
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
`scrub_preferences` and the export filter enforce that
|
|
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
|
+
|
data/lib/pwn/ai/agent/loop.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
data/lib/pwn/memory_index.rb
CHANGED
|
@@ -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
|
-
|
|
88
|
-
|
|
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|
|
data/lib/pwn/plugins/repl.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
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
|
@@ -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(
|