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
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'yaml'
5
+ require 'fileutils'
6
+ require 'time'
7
+
8
+ module PWN
9
+ module AI
10
+ module Agent
11
+ # Per-engagement ACK for exploit/destructive tool calls (PWN-AI-005 tiers).
12
+ module Confirmation
13
+ TIERS = %w[read_only active_scan exploit destructive].freeze
14
+ DEFAULTS = { 'read_only' => 'auto', 'active_scan' => 'auto', 'exploit' => 'prompt', 'destructive' => 'prompt' }.freeze
15
+ AUTONOMOUS = { 'read_only' => 'auto', 'active_scan' => 'auto', 'exploit' => 'auto', 'destructive' => 'auto' }.freeze
16
+
17
+ public_class_method def self.required_bins
18
+ []
19
+ end
20
+
21
+ # Classify a tool call into a PWN-AI-005 side-effect tier.
22
+ public_class_method def self.tier(opts = {})
23
+ name = opts[:name].to_s
24
+ args = opts[:args]
25
+ args = {} unless args.is_a?(Hash)
26
+ tagged = (args[:side_effect] || args['side_effect']).to_s
27
+ return tagged if TIERS.include?(tagged)
28
+ return 'exploit' if name.match?(/exploit|gdb_run_to_crash|fuzz_campaign/)
29
+ return 'active_scan' if name.match?(/nmap_scan|nuclei_scan|sbom_scan|http_proxy/)
30
+ return payload_tier(payload: args[:command] || args['command']) if name == 'shell'
31
+ return payload_tier(payload: args[:code] || args['code']) if name == 'pwn_eval'
32
+
33
+ 'read_only'
34
+ end
35
+
36
+ # Return an ACK pause hash, or nil when the call may proceed.
37
+ public_class_method def self.gate(opts = {})
38
+ name = opts[:name].to_s
39
+ args = opts[:args]
40
+ args = {} unless args.is_a?(Hash)
41
+ klass = tier(name: name, args: args)
42
+ policy = load_policy(opts)
43
+ action = (policy[klass] || DEFAULTS[klass] || 'auto').to_s
44
+ return { success: false, error: "confirmation deny for #{klass}", code: 'ACK_DENY', tier: klass } if action == 'deny'
45
+ return nil unless action == 'prompt'
46
+
47
+ eng = engagement_id(opts.merge(args: args))
48
+ return nil if acked?(engagement_id: eng)
49
+
50
+ if truthy?(value: opts[:operator_ack] || args[:operator_ack] || args['operator_ack'])
51
+ persist_ack(engagement_id: eng)
52
+ return nil
53
+ end
54
+ {
55
+ success: false,
56
+ needs_ack: true,
57
+ code: 'ACK_REQUIRED',
58
+ tier: klass,
59
+ engagement_id: eng,
60
+ diff: intended_diff(name: name, args: args, tier: klass),
61
+ error: "operator ACK required for #{klass}-tier tool call"
62
+ }
63
+ end
64
+
65
+ public_class_method def self.authors
66
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
67
+ end
68
+
69
+ public_class_method def self.help
70
+ puts "USAGE:
71
+ # List host binaries this module expects to be installed.
72
+ #{self}.required_bins
73
+
74
+ # Classify a tool call into a PWN-AI-005 side-effect tier.
75
+ #{self}.tier(
76
+ name: 'required - registry tool name',
77
+ args: 'optional - Hash of tool arguments'
78
+ )
79
+
80
+ # Return an ACK pause hash, or nil when the call may proceed.
81
+ #{self}.gate(
82
+ name: 'required - registry tool name',
83
+ args: 'optional - Hash of tool arguments',
84
+ engagement_id: 'optional - engagement id used to cache the ACK',
85
+ operator_ack: 'optional - true records a one-time ACK for this engagement',
86
+ scope_path: 'optional - scope.yaml path; defaults to ~/.pwn/scope.yaml'
87
+ )
88
+
89
+ # Print the AUTHOR(S) string for this module.
90
+ #{self}.authors
91
+ "
92
+ constants.sort
93
+ end
94
+
95
+ private_class_method def self.payload_tier(opts = {})
96
+ kind = PWN::Plugins::AISandbox.classify(payload: opts[:payload].to_s)
97
+ case kind.to_s
98
+ when 'write' then 'destructive'
99
+ when 'network' then 'active_scan'
100
+ else 'read_only'
101
+ end
102
+ end
103
+
104
+ private_class_method def self.engagement_id(opts = {})
105
+ args = opts[:args]
106
+ args = {} unless args.is_a?(Hash)
107
+ value = (opts[:engagement_id] || args[:engagement_id] || args['engagement_id'] || 'default').to_s
108
+ value = 'default' if value.empty?
109
+ raise ArgumentError, 'engagement_id must be a simple identifier' unless value.match?(/\A[a-zA-Z0-9_-]+\z/)
110
+
111
+ value
112
+ end
113
+
114
+ private_class_method def self.load_policy(opts = {})
115
+ path = opts[:scope_path].to_s
116
+ path = File.join(Dir.home, '.pwn', 'scope.yaml') if path.empty?
117
+ return AUTONOMOUS unless File.file?(path)
118
+
119
+ doc = YAML.safe_load_file(path, permitted_classes: [], aliases: false) || {}
120
+ return AUTONOMOUS unless doc.is_a?(Hash)
121
+
122
+ conf = doc['confirmation'] || doc[:confirmation] || {}
123
+ return AUTONOMOUS unless conf.is_a?(Hash) && !conf.empty?
124
+
125
+ DEFAULTS.merge(conf.transform_keys(&:to_s))
126
+ rescue StandardError
127
+ AUTONOMOUS
128
+ end
129
+
130
+ private_class_method def self.ack_path(opts = {})
131
+ File.join(Dir.home, '.pwn', 'engagements', engagement_id(opts), 'acks.json')
132
+ end
133
+
134
+ private_class_method def self.acked?(opts = {})
135
+ path = ack_path(opts)
136
+ return false unless File.file?(path)
137
+
138
+ JSON.parse(File.read(path))['ack'] == true
139
+ rescue StandardError
140
+ false
141
+ end
142
+
143
+ private_class_method def self.persist_ack(opts = {})
144
+ path = ack_path(opts)
145
+ FileUtils.mkdir_p(File.dirname(path))
146
+ File.write(path, JSON.generate(ack: true, at: Time.now.utc.iso8601))
147
+ File.chmod(0o600, path)
148
+ end
149
+
150
+ private_class_method def self.truthy?(opts = {})
151
+ [true, 'true', 1, '1'].include?(opts[:value])
152
+ end
153
+
154
+ private_class_method def self.intended_diff(opts = {})
155
+ name = opts[:name]
156
+ tier = opts[:tier]
157
+ args = JSON.generate(opts[:args] || {})
158
+ "+ tool: #{name}\n+ tier: #{tier}\n+ args: #{args}"
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
@@ -4,6 +4,7 @@ require 'json'
4
4
  require 'digest'
5
5
  require 'json_schemer'
6
6
  require 'securerandom'
7
+ require 'base64'
7
8
  require 'pwn/ai/agent/tool_guard'
8
9
  require 'pwn/ai/agent/manifest'
9
10
 
@@ -57,10 +58,12 @@ module PWN
57
58
  schema = { allOf: [schema, declaration['params']] } if declaration
58
59
  if raw.nil?
59
60
  required = Array(schema[:required] || schema['required']).map(&:to_s)
60
- return JSON.generate(success: false, error: 'invalid_payload', code: 'SCHEMA_DENY') if required.any? { |key| !ToolGuard.present?(value: args[key.to_sym] || args[key]) }
61
+ missing = required.reject { |key| ToolGuard.present?(value: args[key.to_sym] || args[key]) }
62
+ return schema_denial(type: 'required', error: "Supply required fields: #{missing.join(', ')}") unless missing.empty?
61
63
  end
62
64
  type_schema = drop_required(node: schema)
63
- return JSON.generate(success: false, error: 'invalid_payload', code: 'SCHEMA_DENY') unless args.is_a?(Hash) && JSONSchemer.schema(JSON.parse(JSON.generate(type_schema))).valid?(JSON.parse(JSON.generate(args)))
65
+ violation = JSONSchemer.schema(JSON.parse(JSON.generate(type_schema))).validate(JSON.parse(JSON.generate(args))).first
66
+ return schema_denial(violation.transform_keys(&:to_sym)) if violation
64
67
 
65
68
  blob = args.inspect
66
69
  if defined?(PWN::Plugins::Vault)
@@ -100,6 +103,15 @@ module PWN
100
103
  code: 'CAP_DENY'
101
104
  )
102
105
  end
106
+ ack = Confirmation.gate(
107
+ name: entry.name,
108
+ args: args,
109
+ engagement_id: opts[:engagement_id] || args[:engagement_id] || args['engagement_id'],
110
+ operator_ack: opts[:operator_ack] || args[:operator_ack] || args['operator_ack'],
111
+ scope_path: opts[:scope_path]
112
+ )
113
+ return JSON.generate(ack) if ack
114
+
103
115
  budget = prepare_budget(opts.merge(entry: entry, args: args))
104
116
  return JSON.generate(budget[:denial]) if budget && budget[:denial]
105
117
 
@@ -111,6 +123,7 @@ module PWN
111
123
  raise
112
124
  end
113
125
  telemetry = finish_budget(budget: budget, result: result, elapsed: Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
126
+ result = Result.page(value: result, entry: entry, session_id: opts[:session_id])
114
127
  result = ToolGuard.quarantine_output(text: result) if defined?(ToolGuard) && result.is_a?(String) && ToolGuard.respond_to?(:quarantine_output)
115
128
  note_taint(text: result)
116
129
  if defined?(PWN::Plugins::Vault) && result.is_a?(String)
@@ -169,13 +182,32 @@ module PWN
169
182
  nil
170
183
  end
171
184
 
185
+ private_class_method def self.schema_denial(opts = {})
186
+ pointer = opts[:data_pointer].to_s
187
+ message = opts[:error].to_s
188
+ data = opts[:data]
189
+ # Schema failures identify a JSON field, not a span of command bytes.
190
+ # Do not echo whole argument objects (which may contain credentials).
191
+ token = data.to_s unless data.nil? || data.is_a?(Hash) || data.is_a?(Array)
192
+ denial = ToolGuard.invalid_payload(
193
+ code: 'SCHEMA_DENY', rule_id: "schema.#{opts[:type]}",
194
+ match: token, hint: message,
195
+ remedy: "Correct #{pointer.empty? ? 'the tool arguments' : pointer}: #{message}. Resubmit arguments matching the advertised JSON schema."
196
+ )
197
+ JSON.generate(denial.merge(success: false, data_pointer: pointer))
198
+ end
199
+
172
200
  # Caller owns task lifecycle. Fallback is thread-local, never process-global.
173
201
  private_class_method def self.prepare_budget(opts = {})
174
202
  entry = opts[:entry]
203
+ args = opts[:args]
204
+ if entry.name == 'shell' && (args[:background] == true || (!args.key?(:timeout) && ToolGuard.auto_job?(payload: args[:command].to_s)))
205
+ args[:background] = true
206
+ return nil
207
+ end
175
208
  schema = JSON.parse(JSON.generate(entry.schema))
176
209
  return nil unless schema.dig('parameters', 'properties', 'timeout')
177
210
 
178
- args = opts[:args]
179
211
  ledger = opts[:budget_ledger] || (Thread.current[:pwn_dispatch_budget] ||= {})
180
212
  chains = ledger[:chains] ||= {}
181
213
  key = opts[:budget_key] || entry.name
@@ -496,7 +528,10 @@ module PWN
496
528
  args = opts[:args]
497
529
  args = JSON.parse(args, symbolize_names: true) if args.is_a?(String) && args.strip.start_with?('{')
498
530
  case args
499
- when Hash then args.values.join(' ')
531
+ when Hash
532
+ return Base64.strict_decode64((args[:data] || args['data']).to_s) if (args[:encoding] || args['encoding']).to_s == 'base64'
533
+
534
+ args.values.join(' ')
500
535
  else args.to_s
501
536
  end
502
537
  rescue StandardError
@@ -573,8 +608,11 @@ module PWN
573
608
  budget_key: 'optional - trusted approach identifier; defaults to tool name',
574
609
  scope_policy: 'optional - trusted policy Hash',
575
610
  scope_path: 'optional - trusted scope file path',
611
+ session_id: 'optional - session directory for oversized result artifacts',
576
612
  audit_path: 'optional - trusted audit JSONL path',
577
- approval_callback: 'optional - trusted callback for prompt risk gates'
613
+ approval_callback: 'optional - trusted callback for prompt risk gates',
614
+ engagement_id: 'optional - engagement id used to cache exploit/destructive ACK',
615
+ operator_ack: 'optional - true records a one-time ACK for this engagement'
578
616
  )
579
617
 
580
618
  # Run repair name and return its result
@@ -67,7 +67,7 @@ module PWN
67
67
  public_class_method def self.evidence_satisfied?(opts = {})
68
68
  messages = Array(opts[:messages] || opts[:trace])
69
69
  text = opts[:text].to_s
70
- return false if TurnFinalizer.output_paths(request: opts[:request]).any? && completion_unmet(request: opts[:request], messages: messages).any?
70
+ return false if TurnFinalizer.required_artifacts(request: opts[:request]).any? && completion_unmet(request: opts[:request], messages: messages).any?
71
71
 
72
72
  if defined?(TurnFinalizer) && TurnFinalizer.respond_to?(:arbitrate)
73
73
  row = TurnFinalizer.arbitrate(request: opts[:request].to_s, messages: messages, paths: [])
@@ -783,7 +783,7 @@ module PWN
783
783
  end
784
784
 
785
785
  private_class_method def self.declared_contract(opts = {})
786
- literals = TurnFinalizer.output_paths(request: opts[:request])
786
+ literals = TurnFinalizer.required_artifacts(request: opts[:request])
787
787
  cached = Thread.current[:pwn_loop_deliverables]
788
788
  contract = if cached.is_a?(Array) || cached.is_a?(Hash)
789
789
  normalize_contract(raw: cached)
@@ -908,7 +908,7 @@ module PWN
908
908
  end
909
909
 
910
910
  private_class_method def self.may_finalize?(opts = {})
911
- paths = TurnFinalizer.output_paths(request: opts[:request])
911
+ paths = TurnFinalizer.required_artifacts(request: opts[:request])
912
912
  if paths.any?
913
913
  artifact = TurnFinalizer.arbitrate(request: opts[:request], paths: paths, messages: opts[:messages])
914
914
  unless artifact[:complete]
@@ -1177,7 +1177,7 @@ module PWN
1177
1177
  shape = :timeout
1178
1178
  end
1179
1179
  m = Mistakes.record(tool: name, error: err, args: opts[:args], session_id: opts[:session_id], source: :tool, cause: cause, shape: shape)
1180
- m = Mistakes.extinguish!(signature: m[:signature], args: opts[:args], shape: sem[:shape]) || m if m && defined?(Mistakes) && Mistakes.respond_to?(:extinguish!)
1180
+ m = Mistakes.extinguish!(signature: m[:signature], args: opts[:args], shape: sem[:shape]) || m if m && defined?(Mistakes) && Mistakes.respond_to?(:extinguish!) && !retryable_execution_failure?(raw: raw)
1181
1181
  end
1182
1182
  { ok: sem[:semantic_ok], err: sem[:err], mistake: m, benign: sem[:benign] }
1183
1183
  rescue StandardError
@@ -1330,6 +1330,33 @@ module PWN
1330
1330
  counts[sig]
1331
1331
  end
1332
1332
 
1333
+ # Only trusted structured execution outcomes grant the retry exemption.
1334
+ # Checkpoint/policy/budget denials are not failed command executions.
1335
+ private_class_method def self.retryable_execution_failure?(opts = {})
1336
+ raw = opts[:raw]
1337
+ data = raw.is_a?(Hash) ? raw : JSON.parse(raw.to_s)
1338
+ return false unless data.is_a?(Hash)
1339
+
1340
+ data = data.transform_keys(&:to_s)
1341
+ return false if data['checkpoint'] || data['denied'] || data['code'] || %w[retry_required budget_exhausted].include?(data['error'])
1342
+
1343
+ result = data['result'].is_a?(Hash) ? data['result'].transform_keys(&:to_s) : {}
1344
+ [data, result].any? do |row|
1345
+ row['timed_out'] == true || row['error'].to_s.match?(/\A(?:Timeout::Error:|timeout\b)/i) ||
1346
+ %w[exit exit_code exit_status].any? { |key| row[key].to_s.match?(/\A-?\d+\z/) && !row[key].to_i.zero? }
1347
+ end
1348
+ rescue JSON::ParserError
1349
+ false
1350
+ end
1351
+
1352
+ private_class_method def self.note_payload_result!(opts = {})
1353
+ return unless retryable_execution_failure?(raw: opts[:raw])
1354
+
1355
+ sig = payload_sig(opts)
1356
+ Thread.current[:pwn_same_payload]&.delete(sig)
1357
+ Thread.current[:pwn_extinguished]&.delete(sig)
1358
+ end
1359
+
1333
1360
  private_class_method def self.read_like?(opts = {})
1334
1361
  name = opts[:name].to_s
1335
1362
  return true if name.match?(/_(read|get|list|status|recall|tail)\z/)
@@ -1365,6 +1392,7 @@ module PWN
1365
1392
  hint = opts[:hint].to_s
1366
1393
  thresh = defined?(Mistakes) ? Mistakes::REPEAT_THRESHOLD : 3
1367
1394
  result = "#{result}\n#{hint}" unless hint.empty?
1395
+ return result if retryable_execution_failure?(raw: opts[:raw] || opts[:result])
1368
1396
  return result if count < thresh
1369
1397
 
1370
1398
  if defined?(Mistakes) && Mistakes.respond_to?(:extinguish!)
@@ -2832,6 +2860,7 @@ module PWN
2832
2860
  # )
2833
2861
 
2834
2862
  public_class_method def self.run(opts = {})
2863
+ prior_artifact_contract = Thread.current[:pwn_artifact_contract]
2835
2864
  request = opts[:request].to_s
2836
2865
  session_id = opts[:session_id]
2837
2866
  on_tool = opts[:on_tool]
@@ -2869,6 +2898,8 @@ module PWN
2869
2898
  OpenGoal.begin!(request: request, session_id: session_id)
2870
2899
  end
2871
2900
  Thread.current[:pwn_request_intent] = intent
2901
+ TurnFinalizer.commit_artifacts!(request: request, inherit: nested)
2902
+ required_artifacts = TurnFinalizer.required_artifacts(request: request)
2872
2903
  Thread.current[:pwn_extinguished] = {}
2873
2904
  Thread.current[:pwn_same_payload] = Hash.new(0)
2874
2905
  Thread.current[:pwn_loop_t0] = Time.now unless nested
@@ -2880,9 +2911,10 @@ module PWN
2880
2911
  expose_current_session(session_id: session_id)
2881
2912
  Mistakes.check_user_correction(request: request, session_id: session_id) if defined?(Mistakes)
2882
2913
 
2883
- cheap = opts[:force_tools] != true && %i[greeting howto recall].include?(intent)
2914
+ allow_text_only = required_artifacts.empty? && opts[:force_tools] != true
2915
+ cheap = allow_text_only && %i[greeting howto recall].include?(intent)
2884
2916
 
2885
- if intent == :greeting && opts[:force_tools] != true
2917
+ if allow_text_only && intent == :greeting
2886
2918
  debug_progress(msg: 'path=greeting', debug: opts[:debug])
2887
2919
  quiet_debug_tui!(debug: opts[:debug], reason: 'greeting')
2888
2920
  txt = answer_greeting(
@@ -3242,7 +3274,7 @@ module PWN
3242
3274
  argv_s = args.is_a?(String) ? args.to_s : args.inspect
3243
3275
  debug_progress(msg: "tool #{name} start:\n#{argv_s}", keep_newlines: true, cap: 0, tee: nil)
3244
3276
  sig = payload_sig(name: name, args: args)
3245
- declared_paths = TurnFinalizer.output_paths(request: request)
3277
+ declared_paths = required_artifacts
3246
3278
  arg_paths = tool_arg_paths(args: args)
3247
3279
  watch_paths = (declared_paths + arg_paths).uniq
3248
3280
  before_host = TurnFinalizer.artifact_snapshot(paths: watch_paths)
@@ -3254,9 +3286,10 @@ module PWN
3254
3286
  raw = if same_n >= 3
3255
3287
  checkpoint_result(name: name, args: args)
3256
3288
  else
3257
- Dispatch.call(tool_call: tc)
3289
+ Dispatch.call(tool_call: tc, session_id: session_id)
3258
3290
  end
3259
3291
  end
3292
+ note_payload_result!(name: name, args: args, raw: raw)
3260
3293
  tools_called += 1
3261
3294
  if opts[:verification_contract]
3262
3295
  after_artifacts = Verification.snapshot(opts[:verification_contract])
@@ -3265,7 +3298,7 @@ module PWN
3265
3298
  end
3266
3299
  tele = record_metrics(name: name, action_id: tc[:id], trusted_context: Policy.current_episode&.dig(:trusted_context) || trusted_context,
3267
3300
  started: started, raw: raw, args: args, session_id: session_id, engine: engine, ts_state: ts_state)
3268
- result = Result.condition(content: raw, entry: entry)
3301
+ result = Result.condition(content: raw, entry: entry, session_id: session_id)
3269
3302
 
3270
3303
  unless tele[:ok]
3271
3304
  fkey = Digest::SHA256.hexdigest("#{name}|#{args}")[0, 16]
@@ -3280,11 +3313,11 @@ module PWN
3280
3313
  # P17 — never fork counterfactual when budget fingerprints dominate:
3281
3314
  # CF is another mini agent loop and is the #1 amplifier of
3282
3315
  # iteration-budget exhaustion on this host.
3283
- if count >= thresh && !escalated && defined?(Curriculum) && !budget_exhaustion_hot?
3316
+ if count >= thresh && !retryable_execution_failure?(raw: raw) && !escalated && defined?(Curriculum) && !budget_exhaustion_hot?
3284
3317
  cf = (turn_fails["cf:#{fkey}"] += 1) == 1 ? Curriculum.counterfactual(request: request, name: name, args: args, error: tele[:err] || raw[0, 200], hint: hint) : nil
3285
3318
  hint = "#{hint}\n[pwn-ai/counterfactual] branch #{cf[:branch]} (score=#{cf[:score].round(2)}): #{cf[:content]}" if cf
3286
3319
  end
3287
- result = guard_repeated_failure(name: name, count: count, hint: hint, result: result, mistake: tele[:mistake], args: args, shape: tele.dig(:mistake, :shape))
3320
+ result = guard_repeated_failure(name: name, count: count, hint: hint, raw: raw, result: result, mistake: tele[:mistake], args: args, shape: tele.dig(:mistake, :shape))
3288
3321
  end
3289
3322
 
3290
3323
  on_tool&.call(name, args, result)
@@ -3345,6 +3378,7 @@ module PWN
3345
3378
  end
3346
3379
  raise
3347
3380
  ensure
3381
+ Thread.current[:pwn_artifact_contract] = prior_artifact_contract
3348
3382
  unless nested
3349
3383
  Thread.current[:pwn_loop_active] = nil
3350
3384
  Thread.current[:pwn_loop_deliverables] = nil
@@ -217,7 +217,10 @@ module PWN
217
217
  "(defaults eval=#{eval_s || 20}s shell=#{shell_s || 30}s, clamped). " \
218
218
  "LIVING OFF THE LAND #{land} #{doc}#{jobs} #{facts} LESSONS_REV #{rev} " \
219
219
  "CANARY #{canary} never copy this token into tool args. " \
220
- 'DOMAIN TOOLS for pentest/RE: binary_triage, pty_open, job_run, exploitdev, fuzz_campaign, finding_record, decompile.'
220
+ 'LONG WORK: run fuzzers, full scans and Ghidra analysis through job_run, not blocking shell timeouts. ' \
221
+ 'Use max_runtime for the campaign lifetime (21600 for six hours; 0 unlimited). Retain its id; poll job_status(id) and ' \
222
+ 'read job_tail(id, offset) using next_offset. Current log eof is not job completion. Never relaunch a running job or apply the +180 timeout retry ladder to it. ' \
223
+ 'Use job_status without id to recover jobs from earlier sessions. DOMAIN TOOLS for pentest/RE: binary_triage, pty_open, job_run, exploitdev, fuzz_campaign, finding_record, decompile.'
221
224
  rescue StandardError
222
225
  'load unknown — pass a conservative timeout on pwn_eval/shell anyway.'
223
226
  end
@@ -370,13 +373,13 @@ module PWN
370
373
  request = opts[:request].to_s
371
374
  return '' unless defined?(PWN::AI::Agent::TurnFinalizer)
372
375
 
373
- paths = PWN::AI::Agent::TurnFinalizer.output_paths(request: request)
376
+ paths = PWN::AI::Agent::TurnFinalizer.required_artifacts(request: request)
374
377
  return '' if paths.empty?
375
378
 
376
- rows = paths.map { |path| " - #{path} (must exist, mtime in this turn, non-empty, then read back)" }.join("\n")
379
+ rows = paths.map { |path| " - #{path} (current-turn write-effect tool call, then host stat + SHA-256 readback)" }.join("\n")
377
380
  <<~BLOCK
378
381
  DELIVERABLES
379
- Do not finalize until every path below exists with a fresh mtime and non-trivial size:
382
+ Required artifacts are precommitted. Do not finalize until every path below has a verified current-turn write, fresh mtime, non-trivial size, and stat + SHA-256 readback:
380
383
  #{rows}
381
384
 
382
385
  BLOCK
@@ -43,7 +43,8 @@ module PWN
43
43
 
44
44
  CORE_TOOLS = %w[memory_recall session_recall skills_recall pwn_eval shell
45
45
  mistakes_record mistakes_resolve learning_note_outcome
46
- memory_remember skills_update].freeze
46
+ memory_remember skills_update artifact_read artifact_grep
47
+ job_run job_status job_tail job_result job_kill].freeze
47
48
 
48
49
  # Schema order = CORE_TOOLS. Current session is injected (RECENT TURNS).
49
50
  # Then memory / prior sessions / skills, then pwn_eval before shell.
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+ require 'base64'
5
+
3
6
  module PWN
4
7
  module AI
5
8
  module Agent
6
9
  # Conditioning applied to every tool result before it re-enters the
7
- # conversation as a role:'tool' message: hard size cap + credential
10
+ # conversation as a role:'tool' message: lossless paging + credential
8
11
  # redaction. Keeps the context window bounded and avoids leaking
9
12
  # PWN::Env credentials back into the model.
10
13
  module Result
@@ -36,13 +39,66 @@ module PWN
36
39
 
37
40
  public_class_method def self.condition(opts = {})
38
41
  content = opts[:content].to_s
39
- entry = opts[:entry]
40
- cap = entry ? entry.max_chars : default_max
41
-
42
- content = "#{content[0, cap]}…[truncated #{opts[:content].to_s.length - cap} chars]" if content.length > cap
42
+ content = JSON.generate(spill_summary(content: content, session_id: opts[:session_id])) if content.bytesize > token_limit(entry: opts[:entry]) || !content.dup.force_encoding('UTF-8').valid_encoding?
43
43
  redact(content: content)
44
44
  end
45
45
 
46
+ # Preserve the original handler result before redaction/quarantine.
47
+ public_class_method def self.page(opts = {})
48
+ value = opts[:value]
49
+ safe_value = value.is_a?(String) ? value : transport_value(value: value)
50
+ content = value.is_a?(String) ? value : JSON.generate(safe_value)
51
+ return safe_value if content.bytesize <= token_limit(entry: opts[:entry]) && content.dup.force_encoding('UTF-8').valid_encoding?
52
+
53
+ summary = spill_summary(content: content, session_id: opts[:session_id])
54
+ summary[:serialization] = value.is_a?(String) ? 'raw' : 'json (binary strings use encoding/base64 wrappers)'
55
+ if value.is_a?(Hash)
56
+ %w[success ok exit exit_code exit_status timed_out error code].each do |key|
57
+ next unless value.key?(key.to_sym) || value.key?(key)
58
+
59
+ field = value.key?(key.to_sym) ? value[key.to_sym] : value[key]
60
+ summary[key.to_sym] = field if [true, false, nil].include?(field) || field.is_a?(Numeric) || (field.is_a?(String) && field.bytesize <= 128)
61
+ end
62
+ end
63
+ summary
64
+ end
65
+
66
+ # A byte is a conservative token upper bound; never assume four bytes
67
+ # per token for arbitrary strings/hex/binary dumps. No tokenizer needed.
68
+ public_class_method def self.token_limit(opts = {})
69
+ configured = PWN::Env.dig(:ai, :agent, :artifact_max_tokens).to_i if defined?(PWN::Env)
70
+ limit = configured.to_i.positive? ? configured : default_max / 4
71
+ cap = opts[:entry]&.max_chars.to_i
72
+ limit = [limit, cap].min if cap.positive?
73
+ [limit, 1024].max
74
+ end
75
+
76
+ # Reserve room for JSON escaping, metadata, and the dispatch envelope.
77
+ public_class_method def self.page_length
78
+ ((token_limit - 768) / 8).clamp(32, 2048)
79
+ end
80
+
81
+ private_class_method def self.spill_summary(opts = {})
82
+ content = opts[:content]
83
+ artifact = PWN::Plugins::ArtifactRegistry.spill(bytes: content, session_id: opts[:session_id] || Thread.current[:pwn_session_id] || 'default')
84
+ {
85
+ artifact: artifact,
86
+ summary: 'Full result saved without truncation. Use artifact_read(handle, offset, length) or artifact_grep(handle, regex); offsets are bytes.',
87
+ preview: redact(content: content.byteslice(0, 32).to_s.dup.force_encoding('UTF-8').scrub)
88
+ }
89
+ end
90
+
91
+ private_class_method def self.transport_value(opts = {})
92
+ value = opts[:value]
93
+ case value
94
+ when Hash then value.transform_values { |v| transport_value(value: v) }
95
+ when Array then value.map { |v| transport_value(value: v) }
96
+ when String
97
+ value.dup.force_encoding('UTF-8').valid_encoding? ? value.dup.force_encoding('UTF-8') : { encoding: 'base64', body: Base64.strict_encode64(value) }
98
+ else value
99
+ end
100
+ end
101
+
46
102
  # Engine-aware default: ollama keeps history inside a tight num_ctx;
47
103
  # a 24k tool dump on every call eats the window before useful work.
48
104
  # Override via PWN::Env[:ai][:ollama][:result_max].
@@ -109,9 +165,23 @@ module PWN
109
165
  # Run condition and return its result
110
166
  #{self}.condition(
111
167
  content: 'required - String returned by Dispatch.call',
112
- entry: 'optional - Registry::Entry (used for max_chars; nil → DEFAULT_MAX)'
168
+ entry: 'optional - Registry::Entry (used for max_chars; nil → DEFAULT_MAX)',
169
+ session_id: 'optional - session directory used for lossless spilled results'
113
170
  )
114
171
 
172
+ # Preserve oversized handler results before redaction or quarantine.
173
+ #{self}.page(
174
+ value: 'required - original tool handler result to condition',
175
+ entry: 'optional - registry entry with an inline size cap',
176
+ session_id: 'optional - session directory for the saved result'
177
+ )
178
+
179
+ # Conservative byte-based token bound, configurable through ai.agent.artifact_max_tokens.
180
+ #{self}.token_limit(entry: 'optional - registry entry imposing a smaller cap')
181
+
182
+ # Maximum page bytes with space reserved for response metadata.
183
+ #{self}.page_length
184
+
115
185
  # Engine-aware default: ollama keeps history inside a tight num_ctx;
116
186
  #{self}.default_max
117
187