Module: PWN::AI::Agent::Mission

Defined in:
lib/pwn/ai/agent/mission.rb

Overview

Durable mission ledger. A killed turn resumes the bound DAG instead of re-inferring a plan.

Constant Summary collapse

ROOT =
File.join(Dir.home, '.pwn', 'missions')

Class Method Summary collapse

Class Method Details

.active(opts = {}) ⇒ Object



60
61
62
63
64
65
# File 'lib/pwn/ai/agent/mission.rb', line 60

public_class_method def self.active(opts = {})
  id = (opts[:id] || active_id).to_s
  return nil if id.empty?

  current(id: id)
end

.active_id(opts = {}) ⇒ Object



67
68
69
70
71
72
73
74
75
76
# File 'lib/pwn/ai/agent/mission.rb', line 67

public_class_method def self.active_id(opts = {})
  return opts[:id].to_s unless opts[:id].to_s.empty?

  path = File.join(ROOT, 'active')
  return nil unless File.file?(path)

  File.read(path).strip
rescue StandardError
  nil
end

.authorsObject



235
236
237
# File 'lib/pwn/ai/agent/mission.rb', line 235

public_class_method def self.authors
  "AUTHOR(S):\n  0day Inc. <[email protected]>\n"
end

.begin!(opts = {}) ⇒ Object

Raises:

  • (ArgumentError)


16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# File 'lib/pwn/ai/agent/mission.rb', line 16

public_class_method def self.begin!(opts = {})
  request = opts[:request].to_s.strip
  raise 'ERROR: request is required' if request.empty?

  id = (opts[:id] || SecureRandom.hex(6)).to_s
  raise ArgumentError, 'mission id must be a simple identifier' unless id.match?(/\A[a-zA-Z0-9_-]+\z/)

  row = mutate!(id: id) do |locked|
    locked[:id] = id
    locked[:request] = request
    locked[:status] = 'open'
    locked[:unattended] = opts[:unattended] == true || locked[:unattended] == true
    locked[:min_seconds] = Integer(opts[:min_seconds] || locked[:min_seconds] || 0)
    locked[:started_at] ||= Time.now.utc.iso8601
    locked[:hosts] = Array(locked[:hosts])
    locked[:techniques] = Array(locked[:techniques])
    locked[:finding_ids] = Array(locked[:finding_ids])
    locked[:loot_handles] = Array(locked[:loot_handles])
    locked[:lost_jobs] = Array(locked[:lost_jobs])
    locked[:jobs] = Array(locked[:jobs])
    locked[:shell_exceptions] = Array(locked[:shell_exceptions])
  end
  write_active(id: id) if row[:unattended]
  row
end

.bind_run!(opts = {}) ⇒ Object



78
79
80
81
82
83
84
85
86
87
88
# File 'lib/pwn/ai/agent/mission.rb', line 78

public_class_method def self.bind_run!(opts = {})
  mutate!(id: opts[:id]) do |row|
    raise 'ERROR: mission is required' if row.empty?

    row[:run_id] = opts[:run_id].to_s
    raise 'ERROR: run_id is required' if row[:run_id].empty?

    row[:root] = opts[:root].to_s unless opts[:root].to_s.empty?
    row[:approved] = true
  end
end

.busy?(opts = {}) ⇒ Boolean

Returns:

  • (Boolean)


209
210
211
212
213
214
215
216
217
218
# File 'lib/pwn/ai/agent/mission.rb', line 209

public_class_method def self.busy?(opts = {})
  row = current(id: opts[:id] || active_id)
  return false unless row

  Array(row[:jobs]).any? do |job|
    PWN::Plugins::Jobs.status(id: (job[:id] || job['id']).to_s)[:status].to_s == 'RUNNING'
  end
rescue StandardError
  false
end

.complete!(opts = {}) ⇒ Object



119
120
121
122
123
124
125
126
127
128
129
130
131
# File 'lib/pwn/ai/agent/mission.rb', line 119

public_class_method def self.complete!(opts = {})
  row = current(id: opts[:id])
  return { ok: false, reason: 'missing' } unless row
  return { ok: false, reason: 'duration' } if row[:min_seconds].to_i.positive? && !elapsed?(row: row)
  return { ok: false, reason: 'lost' } unless Array(row[:lost_jobs]).empty?
  return { ok: false, reason: 'checkpoints' } unless checkpoints_done?(row: row)
  return { ok: false, reason: 'findings' } if row[:request].to_s.match?(/finding/i) && Array(row[:finding_ids]).empty?
  return { ok: false, reason: 'loot' } if row[:request].to_s.match?(/loot/i) && Array(row[:loot_handles]).empty?

  mutate!(id: row[:id]) { |locked| locked[:status] = 'done' }
  clear_active(id: row[:id])
  { ok: true }
end

.current(opts = {}) ⇒ Object



53
54
55
56
57
58
# File 'lib/pwn/ai/agent/mission.rb', line 53

public_class_method def self.current(opts = {})
  id = opts[:id].to_s
  return nil if id.empty?

  read_row(id: id)
end

.done?(opts = {}) ⇒ Boolean

Returns:

  • (Boolean)


110
111
112
113
114
115
116
117
# File 'lib/pwn/ai/agent/mission.rb', line 110

public_class_method def self.done?(opts = {})
  row = current(id: opts[:id])
  return true unless row
  return false if row[:min_seconds].to_i.positive? && !elapsed?(row: row)
  return false unless Array(row[:lost_jobs]).empty?

  row[:status].to_s == 'done'
end

.helpObject



239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
# File 'lib/pwn/ai/agent/mission.rb', line 239

public_class_method def self.help
  puts "USAGE:
    # Persist an open mission for the original request.
    #{self}.begin!(
      request: 'required - original operator request',
      id: 'optional - simple mission identifier',
      unattended: 'optional - true fails closed without an approved DAG',
      min_seconds: 'optional - named duration that must elapse before done?'
    )

    # Write the mission ledger and a YAML DAG without running tools.
    #{self}.plan!(
      request: 'required - original operator request',
      id: 'optional - simple mission identifier',
      min_seconds: 'optional - named duration that must elapse before done?'
    )

    # Load one mission ledger.
    #{self}.current(
      id: 'required - mission identifier'
    )

    # Load the active unattended mission.
    #{self}.active(
      id: 'optional - mission identifier; defaults to the active pointer'
    )

    # Read the active mission identifier.
    #{self}.active_id(
      id: 'optional - explicit identifier override'
    )

    # Bind a checkpointed TaskDAG run to this mission.
    #{self}.bind_run!(
      id: 'required - mission identifier',
      run_id: 'required - TaskDAG run id',
      root: 'optional - runs directory'
    )

    # Resume the bound DAG, skipping completed checkpoints.
    #{self}.resume_run(
      id: 'required - mission identifier'
    )

    # False while a named duration or a LOST job is still open.
    #{self}.done?(
      id: 'required - mission identifier'
    )

    # Set status done only when checkpoints, duration, and requested evidence exist.
    #{self}.complete!(
      id: 'required - mission identifier'
    )

    # Append a finding id under the mission file lock.
    #{self}.note_finding!(
      id: 'optional - mission identifier; defaults to the active mission',
      finding_id: 'required - finding identifier',
      host: 'optional - host to record on the ledger'
    )

    # Append a loot handle under the mission file lock.
    #{self}.note_loot!(
      id: 'optional - mission identifier; defaults to the active mission',
      handle: 'required - loot handle',
      host: 'optional - host to record on the ledger'
    )

    # Append a technique name under the mission file lock.
    #{self}.note_technique!(
      id: 'optional - mission identifier; defaults to the active mission',
      technique: 'required - technique or tool name'
    )

    # Append a durable job id under the mission file lock.
    #{self}.note_job!(
      id: 'optional - mission identifier; defaults to the active mission',
      job_id: 'required - Jobs identifier',
      idempotent: 'optional - true when the launch key can be reused',
      log_offset: 'optional - recorded log offset required for reattach',
      command: 'optional - command string stored for a later re-run',
      idempotency_key: 'optional - durable launch key'
    )

    # Record operator-written shell steps that were explicitly allowed.
    #{self}.note_shell_exception!(
      id: 'optional - mission identifier; defaults to the active mission',
      step_ids: 'required - Array of hand-written shell step ids'
    )

    # Record LOST jobs as unknown outcomes, never as success.
    #{self}.record_lost(
      id: 'required - mission identifier',
      jobs: 'required - Array of job status hashes'
    )

    # Classify stored jobs as reattach or LOST without marking success.
    #{self}.recover!(
      id: 'optional - mission identifier; defaults to the active mission'
    )

    # True when a stored job supervisor is still RUNNING.
    #{self}.busy?(
      id: 'optional - mission identifier; defaults to the active mission'
    )

    # Ledger summary for the next model turn. Does not read job logs.
    #{self}.ledger_text(
      id: 'optional - mission identifier; defaults to the active mission',
      request: 'optional - unused request placeholder so the method reads opts'
    )

    # One-line resume report.
    #{self}.report_text(
      report: 'required - TaskDAG resume hash',
      request: 'optional - original request'
    )

    # Print the AUTHOR(S) string for this module.
    #{self}.authors
  "
  constants.sort
end

.ledger_text(opts = {}) ⇒ Object



220
221
222
223
224
225
226
227
# File 'lib/pwn/ai/agent/mission.rb', line 220

public_class_method def self.ledger_text(opts = {})
  _request = opts[:request]
  row = current(id: opts[:id] || active_id)
  return '' unless row

  jobs = Array(row[:jobs]).map { |job| job[:id] || job['id'] }.join(',')
  "MISSION #{row[:id]} status=#{row[:status]} last=#{row[:last_completed_step]} jobs=#{jobs} findings=#{Array(row[:finding_ids]).join(',')} loot=#{Array(row[:loot_handles]).join(',')}\nDecide the next step from this ledger. Do not read job tails."
end

.note_finding!(opts = {}) ⇒ Object



133
134
135
136
137
138
# File 'lib/pwn/ai/agent/mission.rb', line 133

public_class_method def self.note_finding!(opts = {})
  mutate!(id: opts[:id] || active_id) do |row|
    append_unique(row: row, key: :finding_ids, value: opts[:finding_id])
    append_unique(row: row, key: :hosts, value: opts[:host])
  end
end

.note_job!(opts = {}) ⇒ Object



153
154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/pwn/ai/agent/mission.rb', line 153

public_class_method def self.note_job!(opts = {})
  mutate!(id: opts[:id] || active_id) do |row|
    row[:jobs] = Array(row[:jobs])
    jid = opts[:job_id].to_s
    row[:jobs].reject! { |job| (job[:id] || job['id']).to_s == jid }
    row[:jobs] << {
      id: jid,
      idempotent: opts[:idempotent] == true,
      log_offset: opts[:log_offset],
      command: opts[:command].to_s,
      idempotency_key: opts[:idempotency_key].to_s
    }
  end
end

.note_loot!(opts = {}) ⇒ Object



140
141
142
143
144
145
# File 'lib/pwn/ai/agent/mission.rb', line 140

public_class_method def self.note_loot!(opts = {})
  mutate!(id: opts[:id] || active_id) do |row|
    append_unique(row: row, key: :loot_handles, value: opts[:handle])
    append_unique(row: row, key: :hosts, value: opts[:host])
  end
end

.note_shell_exception!(opts = {}) ⇒ Object



168
169
170
171
172
# File 'lib/pwn/ai/agent/mission.rb', line 168

public_class_method def self.note_shell_exception!(opts = {})
  mutate!(id: opts[:id] || active_id) do |row|
    row[:shell_exceptions] = Array(row[:shell_exceptions]) | Array(opts[:step_ids]).map(&:to_s)
  end
end

.note_technique!(opts = {}) ⇒ Object



147
148
149
150
151
# File 'lib/pwn/ai/agent/mission.rb', line 147

public_class_method def self.note_technique!(opts = {})
  mutate!(id: opts[:id] || active_id) do |row|
    append_unique(row: row, key: :techniques, value: opts[:technique])
  end
end

.plan!(opts = {}) ⇒ Object



42
43
44
45
46
47
48
49
50
51
# File 'lib/pwn/ai/agent/mission.rb', line 42

public_class_method def self.plan!(opts = {})
  request = opts[:request].to_s
  row = begin!(id: opts[:id], request: request, unattended: true, min_seconds: opts[:min_seconds])
  dag = PWN::AI::Agent::TaskDAG.plan(request: request)
  dir = File.join(ROOT, row[:id])
  FileUtils.mkdir_p(dir)
  File.write(File.join(dir, 'dag.yaml'), YAML.dump(JSON.parse(JSON.generate(dag))))
  PWN::AI::Agent::OpenGoal.begin!(request: request, mission_id: row[:id]) if defined?(PWN::AI::Agent::OpenGoal)
  dag.merge(mission_id: row[:id])
end

.record_lost(opts = {}) ⇒ Object



174
175
176
177
178
179
180
181
# File 'lib/pwn/ai/agent/mission.rb', line 174

public_class_method def self.record_lost(opts = {})
  row = mutate!(id: opts[:id]) do |locked|
    lost = Array(opts[:jobs]).select { |job| job.is_a?(Hash) && job[:status].to_s == 'LOST' }
    locked[:lost_jobs] = lost.map { |job| job[:id].to_s }.reject(&:empty?)
    locked[:status] = 'open'
  end
  { success: false, lost: row[:lost_jobs] }
end

.recover!(opts = {}) ⇒ Object



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/pwn/ai/agent/mission.rb', line 183

public_class_method def self.recover!(opts = {})
  id = (opts[:id] || active_id).to_s
  row = current(id: id)
  return { success: false, lost: [], reattach: [] } if id.empty? || row.nil?

  lost = []
  reattach = []
  Array(row[:jobs]).each do |job|
    jid = (job[:id] || job['id']).to_s
    status = PWN::Plugins::Jobs.status(id: jid)
    next unless status[:status].to_s == 'LOST'

    offset = job[:log_offset]
    offset = job['log_offset'] if offset.nil?
    if (job[:idempotent] || job['idempotent']) && !offset.nil?
      reattach << jid
    else
      lost << status
    end
  end
  record_lost(id: id, jobs: lost) unless lost.empty?
  { success: false, lost: lost.map { |job| job[:id] }, reattach: reattach }
rescue StandardError
  { success: false, lost: [], reattach: [] }
end

.report_text(opts = {}) ⇒ Object



229
230
231
232
233
# File 'lib/pwn/ai/agent/mission.rb', line 229

public_class_method def self.report_text(opts = {})
  report = opts[:report] || {}
  ids = Array(report[:results]).map { |step| step[:id] || step['id'] }
  "Resumed mission for #{opts[:request]}. Completed steps: #{ids.join(', ')}. Named-duration work stays open until wall time elapses."
end

.resume_run(opts = {}) ⇒ Object



90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
# File 'lib/pwn/ai/agent/mission.rb', line 90

public_class_method def self.resume_run(opts = {})
  row = current(id: opts[:id])
  return { resumed: false, reason: 'missing' } unless row
  return { resumed: false, reason: 'no run' } if row[:run_id].to_s.empty?

  report = PWN::AI::Agent::TaskDAG.resume(
    run_id: row[:run_id],
    root: row[:root],
    approved: row[:approved] == true,
    unattended: row[:unattended]
  )
  ids = Array(report[:results]).map { |step| step[:id] || step['id'] }.compact
  mutate!(id: row[:id]) do |locked|
    locked[:last_completed_step] = ids.last if ids.any?
    locked[:status] = 'open'
  end
  complete!(id: row[:id])
  report.merge(resumed: true, text: report_text(report: report, request: row[:request]))
end