pwn 0.5.739 → 0.5.741

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 (163) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +2 -2
  3. data/documentation/Agent-Tool-Registry.md +163 -6
  4. data/documentation/Diagrams.md +1 -1
  5. data/documentation/Home.md +1 -1
  6. data/documentation/How-PWN-Works.md +1 -1
  7. data/documentation/Plugin-Degradation.md +6 -2
  8. data/documentation/Recon-Findings-API.md +33 -1
  9. data/documentation/What-is-PWN.md +1 -1
  10. data/documentation/pwn-ai-Agent.md +1 -1
  11. data/etc/default_skills/pwn/ai/agent/confirmation/SKILL.md +49 -0
  12. data/etc/default_skills/pwn/ai/agent/result/SKILL.md +4 -1
  13. data/etc/default_skills/pwn/ai/agent/task_dag/SKILL.md +50 -0
  14. data/etc/default_skills/pwn/ai/agent/tool_guard/SKILL.md +2 -0
  15. data/etc/default_skills/pwn/ai/agent/tools/browser_goto/SKILL.md +45 -0
  16. data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +45 -0
  17. data/etc/default_skills/pwn/ai/agent/tools/nmap_scan/SKILL.md +45 -0
  18. data/etc/default_skills/pwn/ai/agent/tools/nuclei_scan/SKILL.md +45 -0
  19. data/etc/default_skills/pwn/ai/agent/tools/radare2/SKILL.md +45 -0
  20. data/etc/default_skills/pwn/ai/agent/turn_finalizer/SKILL.md +3 -0
  21. data/etc/default_skills/pwn/ai/router/SKILL.md +51 -0
  22. data/etc/default_skills/pwn/engagement/SKILL.md +2 -0
  23. data/etc/default_skills/pwn/ffi/capstone/SKILL.md +54 -0
  24. data/etc/default_skills/pwn/ffi/keystone/SKILL.md +50 -0
  25. data/etc/default_skills/pwn/plugins/aflplusplus/SKILL.md +5 -1
  26. data/etc/default_skills/pwn/plugins/ai_sandbox/SKILL.md +52 -0
  27. data/etc/default_skills/pwn/plugins/artifact_registry/SKILL.md +3 -0
  28. data/etc/default_skills/pwn/plugins/assembly/SKILL.md +2 -0
  29. data/etc/default_skills/pwn/plugins/binary_parser/SKILL.md +1 -0
  30. data/etc/default_skills/pwn/plugins/exploit_dev/SKILL.md +1 -0
  31. data/etc/default_skills/pwn/plugins/findings/SKILL.md +1 -0
  32. data/etc/default_skills/pwn/plugins/gdbmi/SKILL.md +8 -2
  33. data/etc/default_skills/pwn/plugins/httpx/SKILL.md +49 -0
  34. data/etc/default_skills/pwn/plugins/jobs/SKILL.md +1 -0
  35. data/etc/default_skills/pwn/plugins/method_catalog/SKILL.md +50 -0
  36. data/etc/default_skills/pwn/plugins/nmap_it/SKILL.md +2 -0
  37. data/etc/default_skills/pwn/plugins/nuclei/SKILL.md +2 -1
  38. data/etc/default_skills/pwn/plugins/process_tube/SKILL.md +2 -0
  39. data/etc/default_skills/pwn/plugins/radare2/SKILL.md +3 -0
  40. data/etc/default_skills/pwn/plugins/recon/SKILL.md +1 -0
  41. data/etc/default_skills/pwn/plugins/rop/SKILL.md +1 -0
  42. data/etc/default_skills/pwn/plugins/transparent_browser/SKILL.md +1 -0
  43. data/etc/default_skills/pwn/plugins/vault/SKILL.md +3 -0
  44. data/etc/default_skills/pwn/reports/SKILL.md +2 -0
  45. data/lib/pwn/ai/agent/confirmation.rb +163 -0
  46. data/lib/pwn/ai/agent/dispatch.rb +43 -5
  47. data/lib/pwn/ai/agent/loop.rb +45 -11
  48. data/lib/pwn/ai/agent/prompt_builder.rb +7 -4
  49. data/lib/pwn/ai/agent/registry.rb +2 -1
  50. data/lib/pwn/ai/agent/result.rb +76 -6
  51. data/lib/pwn/ai/agent/task_dag.rb +220 -0
  52. data/lib/pwn/ai/agent/tool_guard.rb +166 -24
  53. data/lib/pwn/ai/agent/tools/artifacts.rb +35 -5
  54. data/lib/pwn/ai/agent/tools/binary_triage.rb +7 -3
  55. data/lib/pwn/ai/agent/tools/browser_goto.rb +32 -0
  56. data/lib/pwn/ai/agent/tools/debug_lane.rb +23 -0
  57. data/lib/pwn/ai/agent/tools/exploitdev.rb +32 -12
  58. data/lib/pwn/ai/agent/tools/finding_record.rb +31 -5
  59. data/lib/pwn/ai/agent/tools/fuzz_campaign.rb +44 -21
  60. data/lib/pwn/ai/agent/tools/job_run.rb +30 -18
  61. data/lib/pwn/ai/agent/tools/loot_query.rb +44 -0
  62. data/lib/pwn/ai/agent/tools/nmap_scan.rb +32 -0
  63. data/lib/pwn/ai/agent/tools/nuclei_scan.rb +31 -0
  64. data/lib/pwn/ai/agent/tools/radare2.rb +55 -0
  65. data/lib/pwn/ai/agent/tools/ruby_eval.rb +19 -2
  66. data/lib/pwn/ai/agent/tools/shell.rb +22 -10
  67. data/lib/pwn/ai/agent/turn_finalizer.rb +46 -5
  68. data/lib/pwn/ai/agent.rb +3 -1
  69. data/lib/pwn/ai/cli.rb +26 -2
  70. data/lib/pwn/ai/context.rb +4 -2
  71. data/lib/pwn/ai/router.rb +196 -0
  72. data/lib/pwn/ai/tools/README.md +5 -0
  73. data/lib/pwn/ai.rb +1 -0
  74. data/lib/pwn/config.rb +10 -0
  75. data/lib/pwn/engagement.rb +45 -1
  76. data/lib/pwn/ffi/capstone.rb +120 -0
  77. data/lib/pwn/ffi/keystone.rb +107 -0
  78. data/lib/pwn/ffi.rb +2 -0
  79. data/lib/pwn/migrate.rb +5 -1
  80. data/lib/pwn/plugins/aflplusplus.rb +238 -13
  81. data/lib/pwn/plugins/ai_sandbox.rb +240 -0
  82. data/lib/pwn/plugins/artifact_registry.rb +188 -22
  83. data/lib/pwn/plugins/assembly.rb +73 -2
  84. data/lib/pwn/plugins/basic_auth.rb +17 -4
  85. data/lib/pwn/plugins/binary_parser.rb +439 -38
  86. data/lib/pwn/plugins/exploit_dev.rb +83 -14
  87. data/lib/pwn/plugins/findings.rb +208 -42
  88. data/lib/pwn/plugins/gdbmi.rb +265 -38
  89. data/lib/pwn/plugins/httpx.rb +93 -0
  90. data/lib/pwn/plugins/jobs.rb +260 -102
  91. data/lib/pwn/plugins/method_catalog.rb +328 -0
  92. data/lib/pwn/plugins/nmap_it.rb +157 -26
  93. data/lib/pwn/plugins/nuclei.rb +122 -29
  94. data/lib/pwn/plugins/preflight_checker.rb +3 -2
  95. data/lib/pwn/plugins/process_tube.rb +51 -2
  96. data/lib/pwn/plugins/radare2.rb +120 -26
  97. data/lib/pwn/plugins/recon.rb +33 -0
  98. data/lib/pwn/plugins/repl/mesh.rb +203 -13
  99. data/lib/pwn/plugins/rop.rb +121 -31
  100. data/lib/pwn/plugins/transparent_browser.rb +212 -14
  101. data/lib/pwn/plugins/vault.rb +182 -24
  102. data/lib/pwn/plugins.rb +4 -0
  103. data/lib/pwn/reports/engagement.rb +8 -2
  104. data/lib/pwn/reports/html.rb +28 -9
  105. data/lib/pwn/reports/json.rb +2 -1
  106. data/lib/pwn/reports/markdown.rb +49 -12
  107. data/lib/pwn/reports/sarif.rb +17 -3
  108. data/lib/pwn/reports.rb +184 -34
  109. data/lib/pwn/version.rb +1 -1
  110. data/spec/integration/reports_spec.rb +93 -0
  111. data/spec/integration/result_condition_spec.rb +11 -8
  112. data/spec/lib/pwn/ai/agent/confirmation_spec.rb +59 -0
  113. data/spec/lib/pwn/ai/agent/dispatch_confirmation_spec.rb +52 -0
  114. data/spec/lib/pwn/ai/agent/dispatch_policy_spec.rb +51 -0
  115. data/spec/lib/pwn/ai/agent/loop_spec.rb +86 -0
  116. data/spec/lib/pwn/ai/agent/registry_spec.rb +1 -1
  117. data/spec/lib/pwn/ai/agent/result_spec.rb +84 -1
  118. data/spec/lib/pwn/ai/agent/task_dag_spec.rb +63 -0
  119. data/spec/lib/pwn/ai/agent/tool_guard_spec.rb +48 -0
  120. data/spec/lib/pwn/ai/agent/tools/ai_sandbox_spec.rb +36 -0
  121. data/spec/lib/pwn/ai/agent/tools/binary_triage_spec.rb +21 -0
  122. data/spec/lib/pwn/ai/agent/tools/browser_goto_spec.rb +57 -0
  123. data/spec/lib/pwn/ai/agent/tools/exploitdev_spec.rb +42 -0
  124. data/spec/lib/pwn/ai/agent/tools/finding_record_spec.rb +46 -2
  125. data/spec/lib/pwn/ai/agent/tools/fuzz_campaign_spec.rb +46 -0
  126. data/spec/lib/pwn/ai/agent/tools/job_run_spec.rb +52 -0
  127. data/spec/lib/pwn/ai/agent/tools/loot_query_spec.rb +46 -0
  128. data/spec/lib/pwn/ai/agent/tools/nmap_scan_spec.rb +26 -0
  129. data/spec/lib/pwn/ai/agent/tools/nuclei_scan_spec.rb +37 -0
  130. data/spec/lib/pwn/ai/agent/tools/pwn_eval_schema_spec.rb +27 -0
  131. data/spec/lib/pwn/ai/agent/tools/radare2_spec.rb +15 -0
  132. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +76 -0
  133. data/spec/lib/pwn/ai/cli_spec.rb +12 -1
  134. data/spec/lib/pwn/ai/router_spec.rb +57 -0
  135. data/spec/lib/pwn/ffi/capstone_spec.rb +33 -0
  136. data/spec/lib/pwn/ffi/keystone_spec.rb +33 -0
  137. data/spec/lib/pwn/migrate_spec.rb +30 -2
  138. data/spec/lib/pwn/plugins/aflplusplus_spec.rb +40 -0
  139. data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +44 -0
  140. data/spec/lib/pwn/plugins/artifact_registry_spec.rb +116 -1
  141. data/spec/lib/pwn/plugins/assembly_spec.rb +12 -0
  142. data/spec/lib/pwn/plugins/basic_auth_spec.rb +15 -0
  143. data/spec/lib/pwn/plugins/binary_parser_spec.rb +59 -2
  144. data/spec/lib/pwn/plugins/exploit_dev_spec.rb +44 -0
  145. data/spec/lib/pwn/plugins/findings_structured_spec.rb +196 -1
  146. data/spec/lib/pwn/plugins/gdbmi_spec.rb +99 -1
  147. data/spec/lib/pwn/plugins/httpx_spec.rb +23 -0
  148. data/spec/lib/pwn/plugins/jobs_spec.rb +141 -0
  149. data/spec/lib/pwn/plugins/method_catalog_spec.rb +29 -0
  150. data/spec/lib/pwn/plugins/nmap_it_spec.rb +49 -0
  151. data/spec/lib/pwn/plugins/nuclei_spec.rb +33 -1
  152. data/spec/lib/pwn/plugins/radare2_spec.rb +115 -0
  153. data/spec/lib/pwn/plugins/recon_spec.rb +15 -0
  154. data/spec/lib/pwn/plugins/repl_mesh_channel_route_spec.rb +107 -1
  155. data/spec/lib/pwn/plugins/repl_mesh_history_spec.rb +122 -0
  156. data/spec/lib/pwn/plugins/rop_spec.rb +19 -0
  157. data/spec/lib/pwn/plugins/transparent_browser_spec.rb +71 -0
  158. data/spec/lib/pwn/plugins/vault_spec.rb +41 -0
  159. data/spec/lib/pwn/reports/engagement_spec.rb +51 -0
  160. data/spec/lib/pwn/reports_spec.rb +196 -0
  161. data/spec/spec_helper.rb +3 -0
  162. data/third_party/pwn_rdoc.jsonl +230 -4
  163. metadata +47 -5
@@ -25,14 +25,16 @@ PWN::AI::Agent::Registry.register(
25
25
  'Pass timeout as a conservative integer seconds estimate given ' \
26
26
  'HOST LOAD (loadavg, ncpu, mem). Omit for a host-derived default. ' \
27
27
  'Explicit timeout is honored up to 10800s (3 hours) for any payload. On ' \
28
- 'timeout, keep the same payload and retry with timeout += 180 ' \
29
- 'until the 3-hour budget is gone; then rewrite (max 10 mutations/task).',
28
+ 'timeout for short foreground work, keep the same payload and retry with timeout += 180 ' \
29
+ 'until the 3-hour budget is gone; then rewrite (max 10 mutations/task). For fuzz campaigns, full scans, or Ghidra use job_run, or background:true here; poll the returned job id instead of timeout retries.',
30
30
  parameters: {
31
31
  type: 'object',
32
32
  properties: {
33
33
  command: { type: 'string', description: 'The exact shell command to run.' },
34
34
  encoding: { type: 'string', description: 'Set to base64 when data holds the command bytes.' },
35
35
  data: { type: 'string', description: 'Base64 command blob when encoding is base64.' },
36
+ background: { type: 'boolean', description: 'Run detached under the durable job supervisor, bypassing foreground timeout/retry budgets.' },
37
+ max_runtime: { type: 'integer', minimum: 0, description: 'Detached supervisor runtime in seconds; 0 unlimited. Use job_run for cwd/env/idempotency options.' },
36
38
  timeout: {
37
39
  type: 'integer',
38
40
  description: 'Conservative seconds this command should take given HOST LOAD. Omit for a host-derived default. Explicit values honored 1..10800 (3 hours). On timeout keep the same payload and timeout += 180; rewrite only after the 3-hour budget (max 10 mutations/task).'
@@ -47,20 +49,25 @@ PWN::AI::Agent::Registry.register(
47
49
  # Payload bytes are opaque; only the shell interprets command syntax.
48
50
  args = PWN::AI::Agent::ToolGuard.unwrap_payload(args: args, key: :command)
49
51
  args = PWN::AI::Agent::ToolGuard.coerce_args(args: args, required: %w[command])
50
- return PWN::AI::Agent::ToolGuard.invalid_payload(hint: args[:__schema_hint]) if args[:__schema_error]
52
+ return PWN::AI::Agent::ToolGuard.invalid_payload(rule_id: 'payload.required', hint: args[:__schema_hint]) if args[:__schema_error]
51
53
 
52
54
  cmd = args[:command].to_s
53
- if PWN::AI::Agent::ToolGuard.placeholder?(text: cmd, placeholder_ok: args[:placeholder_ok] || args['placeholder_ok'])
55
+ placeholder = PWN::AI::Agent::ToolGuard.placeholder_match(text: cmd, placeholder_ok: args[:placeholder_ok] || args['placeholder_ok'])
56
+ if placeholder
54
57
  return PWN::AI::Agent::ToolGuard.invalid_payload(
58
+ **placeholder,
55
59
  hint: 'command is required (string). Do not send ..., {...}, {…}.',
56
60
  text: cmd,
57
- offending_token: '...'
61
+ remedy: 'Replace the placeholder with complete content or set placeholder_ok: true for intentional literal text. For quoting/transport use encoding: "base64" and data: Base64.strict_encode64(command); base64 does not bypass validation, so retain placeholder_ok: true when needed.'
58
62
  )
59
63
  end
60
- if PWN::AI::Agent::ToolGuard.bashism?(text: cmd) && !PWN::AI::Agent::ToolGuard.shell_bash?
64
+ bashism = PWN::AI::Agent::ToolGuard.bashism_match(text: cmd)
65
+ if bashism && !PWN::AI::Agent::ToolGuard.shell_bash?
61
66
  return PWN::AI::Agent::ToolGuard.invalid_payload(
67
+ **bashism,
62
68
  hint: 'bash-only construct (PIPESTATUS/RANDOM/[[) is not POSIX. Rewrite for /bin/sh or set ai.agent.shell_bash.',
63
- text: cmd
69
+ text: cmd,
70
+ remedy: 'Rewrite the matched construct for /bin/sh or enable ai.agent.shell_bash. Neither base64 nor placeholder_ok bypasses shell-syntax validation.'
64
71
  )
65
72
  end
66
73
  timeout = PWN::AI::Agent::ToolGuard.deadline_s(timeout: args[:timeout], kind: :shell, payload: cmd)
@@ -69,15 +76,20 @@ PWN::AI::Agent::Registry.register(
69
76
  hint: "command exceeds max payload size #{PWN::AI::Agent::ToolGuard::MAX_PAYLOAD_BYTES} bytes",
70
77
  text: cmd,
71
78
  byte_range: [PWN::AI::Agent::ToolGuard::MAX_PAYLOAD_BYTES, cmd.bytesize],
72
- code: 'PAYLOAD_TOO_LARGE'
79
+ code: 'PAYLOAD_TOO_LARGE',
80
+ rule_id: 'payload.max_bytes',
81
+ remedy: 'Split the command into smaller complete calls within max_payload_bytes. Base64 and placeholder_ok do not increase the decoded size limit.'
73
82
  )
74
83
  end
75
- return PWN::AI::Agent::ToolGuard.invalid_payload(hint: 'command must be a nonempty string') unless args[:command].is_a?(String) && !cmd.empty?
84
+ return PWN::AI::Agent::ToolGuard.invalid_payload(rule_id: 'payload.type', hint: 'command must be a nonempty string') unless args[:command].is_a?(String) && !cmd.empty?
76
85
 
77
86
  scoped = PWN::AI::Agent::ToolGuard.scope_refusal(command: cmd)
78
87
  return scoped if scoped
79
88
 
80
- return PWN::Plugins::Jobs.start(command: cmd).merge(routed: 'job_run') if args[:timeout].to_i <= 0 && PWN::AI::Agent::ToolGuard.auto_job?(payload: cmd)
89
+ sandboxed = PWN::Plugins::AISandbox.wrap_shell(command: cmd, timeout: timeout)
90
+ return sandboxed unless sandboxed.nil?
91
+
92
+ return PWN::Plugins::Jobs.start(command: cmd, max_runtime: args[:max_runtime], session_id: Thread.current[:pwn_session_id]).merge(routed: 'job_run') if args[:background] == true || (args[:timeout].to_i <= 0 && PWN::AI::Agent::ToolGuard.auto_job?(payload: cmd))
81
93
 
82
94
  stdout = +''
83
95
  stderr = +''
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
  require 'digest'
5
+ require 'securerandom'
5
6
 
6
7
  module PWN
7
8
  module AI
@@ -178,6 +179,27 @@ module PWN
178
179
  MUTEX.synchronize { THREADS.count(&:alive?) }
179
180
  end
180
181
 
182
+ # Freeze operator-owned destinations once, before tools can change cwd.
183
+ public_class_method def self.commit_artifacts!(opts = {})
184
+ return Thread.current[:pwn_artifact_contract] if opts[:inherit] && Thread.current[:pwn_artifact_contract]
185
+
186
+ request = opts[:request].to_s.dup.freeze
187
+ paths = output_paths(request: request, cwd: opts[:cwd]).map(&:freeze).freeze
188
+ Thread.current[:pwn_artifact_contract] = {
189
+ id: SecureRandom.hex(16).freeze,
190
+ request: request,
191
+ required_artifacts: paths,
192
+ started_at: Time.now.freeze
193
+ }.freeze
194
+ end
195
+
196
+ public_class_method def self.required_artifacts(opts = {})
197
+ contract = Thread.current[:pwn_artifact_contract]
198
+ return contract[:required_artifacts] if contract && (contract[:request] == opts[:request].to_s || Thread.current[:pwn_loop_nested])
199
+
200
+ output_paths(request: opts[:request], cwd: opts[:cwd])
201
+ end
202
+
181
203
  # Literal destinations only; source paths are never delivery obligations.
182
204
  public_class_method def self.output_paths(opts = {})
183
205
  request = opts[:request].to_s
@@ -211,9 +233,13 @@ module PWN
211
233
  return {} unless opts[:success] == true && opts[:effect].to_s == 'write'
212
234
 
213
235
  before = opts[:before] || {}
214
- artifact_snapshot(paths: opts[:paths]).select do |path, row|
236
+ observed = artifact_snapshot(paths: opts[:paths]).select do |path, row|
215
237
  row && before.key?(path) && before[path] != row
216
238
  end
239
+ observed.transform_values do |row|
240
+ contract = Thread.current[:pwn_artifact_contract]
241
+ contract ? row.merge(artifact_turn_id: contract[:id]) : row
242
+ end
217
243
  end
218
244
 
219
245
  private_class_method def self.artifact_readback(opts = {})
@@ -242,9 +268,9 @@ module PWN
242
268
  public_class_method def self.arbitrate(opts = {})
243
269
  request = opts[:request].to_s
244
270
  messages = Array(opts[:messages])
245
- t0 = opts[:session_t0] || Thread.current[:pwn_loop_t0]
271
+ t0 = opts[:session_t0] || Thread.current[:pwn_artifact_contract]&.dig(:started_at) || Thread.current[:pwn_loop_t0]
246
272
  paths = Array(opts[:paths]).map { |path| File.expand_path(path.to_s) }
247
- paths += output_paths(request: request)
273
+ paths += required_artifacts(request: request)
248
274
  paths.uniq!
249
275
  ledger = evidence_ledger(messages: messages)
250
276
  unmet = []
@@ -257,7 +283,10 @@ module PWN
257
283
  unmet << { criterion: 'empty_artifact', detail: path } if File.size(path) < 2
258
284
  unmet << { criterion: 'write_missing', detail: path } unless row && row[:write]
259
285
  unmet << { criterion: 'readback_missing', detail: path } unless row && row[:read]
260
- unmet << { criterion: 'artifact_mtime_before_session', detail: path } if t0 && File.mtime(path) < t0
286
+ # Filesystem timestamps can lag Time.now by a clock tick. A bound
287
+ # current-turn write delta plus matching readback is stronger proof.
288
+ current_turn = row && row.dig(:evidence, :artifact_turn_id) == Thread.current[:pwn_artifact_contract]&.dig(:id) && Thread.current[:pwn_artifact_contract]
289
+ unmet << { criterion: 'artifact_mtime_before_session', detail: path } if t0 && File.mtime(path) < t0 && !current_turn
261
290
  end
262
291
  {
263
292
  complete: unmet.empty? && (!paths.empty? || ledger.any?),
@@ -274,8 +303,14 @@ module PWN
274
303
  next unless msg[:artifact_observations].is_a?(Hash)
275
304
 
276
305
  msg[:artifact_observations].each do |path, observed|
306
+ next unless observed.is_a?(Hash)
307
+
308
+ contract = Thread.current[:pwn_artifact_contract]
309
+ next if contract && observed[:artifact_turn_id] != contract[:id]
310
+
277
311
  current = artifact_readback(path: path)
278
- next unless current && current == observed
312
+ readback = observed.except(:artifact_turn_id)
313
+ next unless current && current == readback
279
314
 
280
315
  ledger[path] = { write: true, read: true, evidence: observed }
281
316
  end
@@ -338,6 +373,12 @@ module PWN
338
373
  # Extract explicit output literals, excluding source/read paths.
339
374
  #{self}.output_paths(request: 'required - original user request', cwd: 'optional - relative path base')
340
375
 
376
+ # Precommit immutable required_artifacts for this turn before tools run.
377
+ #{self}.commit_artifacts!(request: 'required - original user request', cwd: 'optional - initial relative path base', inherit: 'optional - reuse the owning turn contract for a nested call')
378
+
379
+ # Return precommitted paths, or parse literals outside an active turn.
380
+ #{self}.required_artifacts(request: 'required - original user request', cwd: 'optional - relative path base outside a turn')
381
+
341
382
  # Snapshot named destinations immediately before a tool runs.
342
383
  #{self}.artifact_snapshot(paths: 'required - destination paths')
343
384
 
data/lib/pwn/ai/agent.rb CHANGED
@@ -23,7 +23,8 @@ module PWN
23
23
  autoload :RequestRuntime, 'pwn/ai/agent/request_runtime'
24
24
  autoload :Profiles, 'pwn/ai/agent/profiles'
25
25
  autoload :EngagementMemory, 'pwn/ai/agent/engagement_memory'
26
- autoload :Dispatch, 'pwn/ai/agent/dispatch'
26
+ autoload :Dispatch, 'pwn/ai/agent/dispatch'
27
+ autoload :Confirmation, 'pwn/ai/agent/confirmation'
27
28
  autoload :Result, 'pwn/ai/agent/result'
28
29
  autoload :PromptBuilder, 'pwn/ai/agent/prompt_builder'
29
30
  autoload :Loop, 'pwn/ai/agent/loop'
@@ -38,6 +39,7 @@ module PWN
38
39
  autoload :Verification, 'pwn/ai/agent/verification'
39
40
  autoload :Curriculum, 'pwn/ai/agent/curriculum'
40
41
  autoload :TaskSummarizer, 'pwn/ai/agent/task_summarizer'
42
+ autoload :TaskDAG, 'pwn/ai/agent/task_dag'
41
43
  autoload :Policy, 'pwn/ai/agent/policy'
42
44
  autoload :PolicyEvaluation, 'pwn/ai/agent/policy_evaluation'
43
45
  autoload :ToolGuard, 'pwn/ai/agent/tool_guard'
data/lib/pwn/ai/cli.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'optparse'
4
+ require 'yaml'
5
+ require 'json'
4
6
  require 'pwn'
5
7
 
6
8
  module PWN
@@ -10,10 +12,13 @@ module PWN
10
12
  public_class_method def self.parse(opts = {})
11
13
  result = {}
12
14
  parser = OptionParser.new do |options|
13
- options.banner = 'Usage: pwn-ai [--analyze PATH | --replay ID | --rerun ID] [--ai PROMPT]'
15
+ options.banner = 'Usage: pwn-ai [--analyze PATH | --replay ID | --rerun ID | --plan-only | --execute PATH | --resume RUN_ID] [--ai PROMPT]'
14
16
  options.on('--analyze PATH', 'Ingest evidence before starting the AI session') { |v| result[:analyze] = v }
15
17
  options.on('--replay ID', 'Render saved trace without executing tools') { |v| result[:replay] = v }
16
18
  options.on('--rerun ID', 'Re-execute saved tools in a fresh context (side effects possible)') { |v| result[:rerun] = v }
19
+ options.on('--plan-only', 'Emit a YAML task DAG with zero side effects') { result[:plan_only] = true }
20
+ options.on('--execute PATH', 'Run an approved YAML task DAG') { |v| result[:execute] = v }
21
+ options.on('--resume RUN_ID', 'Resume a checkpointed DAG run, skipping completed steps') { |v| result[:resume] = v }
17
22
  options.on('--ai PROMPT', 'One-shot request; - reads standard input') { |v| result[:ai] = v }
18
23
  options.on('--pwn-env PATH', 'Use the specified encrypted configuration') { |v| result[:pwn_env_path] = v }
19
24
  options.on('--pwn-dec PATH', 'Use the specified decryptor') { |v| result[:pwn_dec_path] = v }
@@ -21,8 +26,9 @@ module PWN
21
26
  end
22
27
  remaining = parser.parse!(Array(opts[:argv]).dup)
23
28
  raise OptionParser::InvalidArgument, "unexpected arguments: #{remaining.join(' ')}" unless remaining.empty?
24
- raise OptionParser::InvalidArgument, 'choose only one of --analyze, --replay, --rerun' if %i[analyze replay rerun].count { |key| result.key?(key) } > 1
29
+ raise OptionParser::InvalidArgument, 'choose only one of --analyze, --replay, --rerun, --plan-only, --execute, --resume' if %i[analyze replay rerun plan_only execute resume].count { |key| result.key?(key) } > 1
25
30
  raise OptionParser::InvalidArgument, '--ai cannot accompany --replay or --rerun' if result[:ai] && (result[:replay] || result[:rerun])
31
+ raise OptionParser::InvalidArgument, '--plan-only requires --ai' if result[:plan_only] && !result[:ai]
26
32
 
27
33
  result[:help_text] = parser.to_s
28
34
  result
@@ -37,6 +43,24 @@ module PWN
37
43
  end
38
44
 
39
45
  require 'pwn'
46
+ if parsed[:plan_only]
47
+ prompt = parsed[:ai] == '-' ? (opts[:input] || $stdin).read : parsed[:ai]
48
+ raise ArgumentError, '--ai requires a non-empty prompt' if prompt.to_s.strip.empty?
49
+
50
+ dag = PWN::AI::Agent::TaskDAG.plan(request: prompt)
51
+ output.puts(YAML.dump(JSON.parse(JSON.generate(dag))))
52
+ return 0
53
+ end
54
+ if parsed[:resume] || parsed[:execute]
55
+ PWN::Config.refresh_env(**parsed.slice(:pwn_env_path, :pwn_dec_path))
56
+ report = if parsed[:resume]
57
+ PWN::AI::Agent::TaskDAG.resume(run_id: parsed[:resume])
58
+ else
59
+ PWN::AI::Agent::TaskDAG.execute(path: parsed[:execute])
60
+ end
61
+ output.puts(JSON.generate(report))
62
+ return report[:ok] == false ? 1 : 0
63
+ end
40
64
  if parsed[:replay] || parsed[:rerun]
41
65
  require 'pwn/session_trace'
42
66
  if parsed[:replay]
@@ -49,7 +49,8 @@ module PWN
49
49
  text = if defined?(PWN::Plugins::Radare2) && File.file?(path)
50
50
  sid = PWN::Plugins::Radare2.open(path: path)
51
51
  addr = fn.empty? ? 'main' : fn
52
- PWN::Plugins::Radare2.disasm(session: sid, addr: addr, n: 64)
52
+ result = PWN::Plugins::Radare2.disasm(session: sid, addr: addr, n: 64)
53
+ result.is_a?(String) ? result : JSON.pretty_generate(result)
53
54
  else
54
55
  File.binread(path).to_s[0, CHUNK]
55
56
  end
@@ -136,8 +137,9 @@ module PWN
136
137
  File.binwrite(dest, bytes)
137
138
  { sha256: sha, path: dest, size: bytes.bytesize }
138
139
  end
139
- chunks = []
140
140
  body = summary.to_s
141
+ body = PWN::AI::Router.summarize(text: body) if bytes.bytesize > WINDOW && defined?(PWN::AI::Router)
142
+ chunks = []
141
143
  i = 0
142
144
  while i < body.bytesize
143
145
  chunks << body.byteslice(i, CHUNK)
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module PWN
6
+ module AI
7
+ # Task-class model routing from pwn.yaml ai_router / model_routes.
8
+ module Router
9
+ TASKS = %i[summarize plan exploit_dev triage].freeze
10
+ FRONTIER = %w[openai anthropic grok gemini].freeze
11
+ LOCAL = %w[ollama openwebui].freeze
12
+ ENGINE_MOD = {
13
+ 'ollama' => 'PWN::AI::Ollama',
14
+ 'openwebui' => 'PWN::AI::OpenWebUI',
15
+ 'openai' => 'PWN::AI::OpenAI',
16
+ 'anthropic' => 'PWN::AI::Anthropic',
17
+ 'grok' => 'PWN::AI::Grok',
18
+ 'gemini' => 'PWN::AI::Gemini'
19
+ }.freeze
20
+
21
+ public_class_method def self.required_bins
22
+ []
23
+ end
24
+
25
+ # Resolve engine/model fallbacks for a task class.
26
+ public_class_method def self.resolve(opts = {})
27
+ task = (opts[:task] || opts[:class] || :summarize).to_s.to_sym
28
+ entry = config_entry(task: task)
29
+ endpoints = expand_endpoints(entry: entry)
30
+ allow_frontier = !%i[summarize plan].include?(task)
31
+ allow_frontier &&= entry[:allow_frontier] != false && entry['allow_frontier'] != false
32
+ { task: task, endpoints: endpoints, allow_frontier: allow_frontier }
33
+ end
34
+
35
+ # Summarize text on the cheap local chain; never calls frontier models.
36
+ public_class_method def self.summarize(opts = {})
37
+ text = (opts[:text] || opts[:content]).to_s
38
+ reset_task = opts[:reset_usage]
39
+ reset_usage if reset_task
40
+ note_usage(task: :summarize, frontier_tokens: 0)
41
+ if opts[:llm]
42
+ resolve(task: :summarize)[:endpoints].each do |endpoint|
43
+ next if frontier?(engine: endpoint[:engine])
44
+
45
+ out = try_chat(endpoint: endpoint, request: "Summarize this artifact for paging. Be terse.\n#{text.byteslice(0, 8_192)}")
46
+ return out if out.to_s.strip != ''
47
+ end
48
+ end
49
+ extractive(text: text)
50
+ rescue StandardError
51
+ extractive(text: text)
52
+ end
53
+
54
+ # Return per-task token accounting (frontier_tokens is always 0 for summarize).
55
+ public_class_method def self.usage(opts = {})
56
+ _n = opts[:n]
57
+ @usage ||= {}
58
+ @usage
59
+ end
60
+
61
+ # Clear the in-process usage ledger.
62
+ public_class_method def self.reset_usage(opts = {})
63
+ _n = opts[:n]
64
+ @usage = {}
65
+ end
66
+
67
+ public_class_method def self.authors
68
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
69
+ end
70
+
71
+ public_class_method def self.help
72
+ puts "USAGE:
73
+ # List host binaries this module expects to be installed.
74
+ #{self}.required_bins
75
+
76
+ # Resolve engine/model fallbacks for a task class.
77
+ #{self}.resolve(
78
+ task: 'optional - summarize, plan, exploit_dev, or triage',
79
+ class: 'optional - alias for task'
80
+ )
81
+
82
+ # Summarize text on the cheap local chain; never calls frontier models.
83
+ #{self}.summarize(
84
+ text: 'optional - artifact or page text to summarize',
85
+ content: 'optional - alias for text',
86
+ reset_usage: 'optional - true clears the usage ledger first',
87
+ llm: 'optional - true tries local-engine chat after skipping frontier endpoints'
88
+ )
89
+
90
+ # Return per-task token accounting (frontier_tokens is always 0 for summarize).
91
+ #{self}.usage(
92
+ n: 'optional - unused placeholder so the method reads opts'
93
+ )
94
+
95
+ # Clear the in-process usage ledger.
96
+ #{self}.reset_usage(
97
+ n: 'optional - unused placeholder so the method reads opts'
98
+ )
99
+
100
+ # Print the AUTHOR(S) string for this module.
101
+ #{self}.authors
102
+ "
103
+ constants.sort
104
+ end
105
+
106
+ private_class_method def self.config_entry(opts = {})
107
+ task = opts[:task]
108
+ router = env_hash(key: :ai_router)
109
+ routes = env_dig(keys: %i[ai agent model_routes])
110
+ raw = (router[task] || router[task.to_s] || routes[task] || routes[task.to_s]).dup
111
+ raw = default_entry(task: task) if raw.nil? || raw == {}
112
+ raw.is_a?(Hash) ? raw.transform_keys(&:to_sym) : { engine: raw.to_s }
113
+ end
114
+
115
+ private_class_method def self.default_entry(opts = {})
116
+ task = opts[:task]
117
+ case task
118
+ when :exploit_dev
119
+ { engine: 'ollama', model: 'qwen2.5-coder', fallbacks: [{ engine: 'openai' }] }
120
+ when :summarize, :plan, :triage
121
+ { engine: 'ollama', fallbacks: [{ engine: 'openwebui' }], allow_frontier: false }
122
+ else
123
+ { engine: env_dig(keys: %i[ai active]).to_s }
124
+ end
125
+ end
126
+
127
+ private_class_method def self.expand_endpoints(opts = {})
128
+ entry = opts[:entry]
129
+ head = { engine: (entry[:engine] || entry[:endpoint] || 'ollama').to_s, model: entry[:model] }
130
+ rest = Array(entry[:fallbacks] || entry[:fallback]).map do |item|
131
+ item.is_a?(Hash) ? item.transform_keys(&:to_sym) : { engine: item.to_s }
132
+ end
133
+ ([head] + rest).map { |row| { engine: row[:engine].to_s.downcase, model: row[:model] } }
134
+ end
135
+
136
+ private_class_method def self.frontier?(opts = {})
137
+ FRONTIER.include?(opts[:engine].to_s.downcase)
138
+ end
139
+
140
+ private_class_method def self.try_chat(opts = {})
141
+ endpoint = opts[:endpoint]
142
+ engine = endpoint[:engine].to_s
143
+ const = ENGINE_MOD[engine]
144
+ return nil unless const
145
+
146
+ mod = Object.const_get(const)
147
+ return nil unless mod.respond_to?(:chat)
148
+
149
+ resp = mod.chat(request: opts[:request], model: endpoint[:model], spinner: false, quiet: true, timeout: 8)
150
+ text = resp.is_a?(Hash) ? (resp[:content] || resp['content'] || resp.dig(:choices, 0, :content)).to_s : resp.to_s
151
+ text.strip.empty? ? nil : text
152
+ rescue StandardError
153
+ nil
154
+ end
155
+
156
+ private_class_method def self.extractive(opts = {})
157
+ text = opts[:text].to_s
158
+ text.byteslice(0, 2_048).to_s
159
+ end
160
+
161
+ private_class_method def self.note_usage(opts = {})
162
+ task = opts[:task].to_sym
163
+ @usage ||= {}
164
+ @usage[task] ||= { frontier_tokens: 0, local_tokens: 0 }
165
+ @usage[task][:frontier_tokens] = opts[:frontier_tokens].to_i if opts.key?(:frontier_tokens)
166
+ @usage[task][:local_tokens] += opts[:local_tokens].to_i if opts[:local_tokens]
167
+ end
168
+
169
+ private_class_method def self.env_hash(opts = {})
170
+ return {} unless defined?(PWN::Env)
171
+
172
+ val = PWN::Env[opts[:key]]
173
+ if val.is_a?(Hash)
174
+ val.transform_keys { |key| key.is_a?(String) ? key.to_sym : key }
175
+ else
176
+ {}
177
+ end
178
+ rescue StandardError
179
+ {}
180
+ end
181
+
182
+ private_class_method def self.env_dig(opts = {})
183
+ return {} unless defined?(PWN::Env) && PWN::Env.respond_to?(:dig)
184
+
185
+ val = PWN::Env.dig(*opts[:keys])
186
+ if val.is_a?(Hash)
187
+ val.transform_keys { |key| key.is_a?(String) ? key.to_sym : key }
188
+ else
189
+ val
190
+ end
191
+ rescue StandardError
192
+ {}
193
+ end
194
+ end
195
+ end
196
+ end
@@ -20,6 +20,11 @@ risk_gates:
20
20
  med: prompt
21
21
  high: prompt
22
22
  crit: deny
23
+ confirmation:
24
+ read_only: auto
25
+ active_scan: auto
26
+ exploit: prompt
27
+ destructive: prompt
23
28
  ```
24
29
 
25
30
  Missing risk gates deny; unannotated tools use `crit`. `enabled: false` explicitly
data/lib/pwn/ai.rb CHANGED
@@ -17,6 +17,7 @@ module PWN
17
17
  autoload :OpenAI, 'pwn/ai/open_ai'
18
18
  autoload :OpenWebUI, 'pwn/ai/open_web_ui'
19
19
  autoload :RedTeam, 'pwn/ai/red_team'
20
+ autoload :Router, 'pwn/ai/router'
20
21
 
21
22
  # Display a List of Every PWN::AI Module
22
23
 
data/lib/pwn/config.rb CHANGED
@@ -17,6 +17,13 @@ module PWN
17
17
  # user values on `pwn setup --migrate --fix`
18
18
  public_class_method def self.env_template
19
19
  {
20
+ ai_sandbox: 'off',
21
+ ai_router: {
22
+ summarize: { engine: 'ollama', model: nil, fallbacks: [{ engine: 'openwebui' }], allow_frontier: false },
23
+ plan: { engine: 'ollama', model: nil, fallbacks: [{ engine: 'openwebui' }], allow_frontier: false },
24
+ exploit_dev: { engine: 'ollama', model: 'qwen2.5-coder', fallbacks: [{ engine: 'openai' }] },
25
+ triage: { engine: 'ollama', model: nil, fallbacks: [{ engine: 'openwebui' }] }
26
+ },
20
27
  ai: {
21
28
  active: 'grok',
22
29
  module_reflection: false,
@@ -204,6 +211,9 @@ module PWN
204
211
  max_total_mb: 512,
205
212
  model_routes: {
206
213
  summarize: nil,
214
+ plan: nil,
215
+ exploit_dev: nil,
216
+ triage: nil,
207
217
  judge: nil,
208
218
  act: nil,
209
219
  sensitive: nil
@@ -3,6 +3,7 @@
3
3
  require 'json'
4
4
  require 'fileutils'
5
5
  require 'ipaddr'
6
+ require 'time'
6
7
 
7
8
  module PWN
8
9
  # Engagement scope, host state, and vault refs under ~/.pwn/engagements.
@@ -85,6 +86,31 @@ module PWN
85
86
  hosts(name: opts[:engagement] || opts[:name])
86
87
  end
87
88
 
89
+ public_class_method def self.record_scan(opts = {})
90
+ name = (opts[:engagement] || opts[:name] || PWN::AI::Agent::Engagement.current_name || 'default').to_s
91
+ dir = File.join(PWN::AI::Agent::Engagement::ROOT, name, 'scans')
92
+ FileUtils.mkdir_p(dir)
93
+ at = opts[:at] || Time.now.utc
94
+ at = Time.parse(at.to_s).utc unless at.is_a?(Time)
95
+ path = File.join(dir, "nmap-#{at.utc.strftime('%Y%m%dT%H%M%SZ')}.json")
96
+ body = {
97
+ at: at.utc.iso8601,
98
+ kind: opts[:kind] || 'nmap',
99
+ xml: opts[:xml],
100
+ hosts: opts[:hosts],
101
+ ports: opts[:ports]
102
+ }
103
+ File.write(path, JSON.pretty_generate(body))
104
+ body.merge(path: path)
105
+ end
106
+
107
+ public_class_method def self.scans(opts = {})
108
+ name = (opts[:name] || opts[:engagement] || PWN::AI::Agent::Engagement.current_name || 'default').to_s
109
+ Dir[File.join(PWN::AI::Agent::Engagement::ROOT, name, 'scans', 'nmap-*.json')].map do |path|
110
+ JSON.parse(File.read(path), symbolize_names: true).merge(path: path)
111
+ end
112
+ end
113
+
88
114
  public_class_method def self.authors
89
115
  "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
90
116
  end
@@ -147,7 +173,25 @@ module PWN
147
173
  #{self}.merge_scan(
148
174
  hosts: 'optional - Array of {host, port, service} hashes',
149
175
  results: 'optional - alias for hosts',
150
- override: 'optional - true records out-of-scope hosts'
176
+ override: 'optional - true records out-of-scope hosts',
177
+ engagement: 'optional - engagement identifier (defaults to active)'
178
+ )
179
+
180
+ # Persist a timestamped nmap inventory snapshot under the engagement scans/ dir.
181
+ #{self}.record_scan(
182
+ hosts: 'required - inventory host array from NmapIt',
183
+ ports: 'optional - flattened port rows',
184
+ xml: 'optional - source nmap XML path',
185
+ at: 'optional - Time or ISO8601 timestamp of the scan',
186
+ kind: 'optional - scanner kind (defaults to nmap)',
187
+ engagement: 'optional - engagement identifier',
188
+ name: 'optional - alias for engagement'
189
+ )
190
+
191
+ # List persisted nmap snapshots oldest-first.
192
+ #{self}.scans(
193
+ name: 'optional - engagement identifier',
194
+ engagement: 'optional - alias for name'
151
195
  )
152
196
 
153
197
  # Print the AUTHOR(S) string for this module.