pwn 0.5.562 → 0.5.587
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/.rubocop.yml +1 -1
- data/.rubocop_todo.yml +4 -3
- data/.ruby-version +1 -1
- data/Gemfile +17 -17
- data/README.md +5 -5
- data/Rakefile +8 -4
- data/build_gem.sh +19 -10
- data/documentation/lifecycle_authz_replay.example.yaml +27 -0
- data/git_commit.sh +6 -1
- data/lib/pwn/ai/anthropic.rb +281 -0
- data/lib/pwn/ai/grok.rb +8 -3
- data/lib/pwn/ai/introspection.rb +9 -0
- data/lib/pwn/ai/open_ai.rb +6 -2
- data/lib/pwn/ai.rb +1 -0
- data/lib/pwn/bounty/lifecycle_authz_replay.rb +505 -0
- data/lib/pwn/bounty.rb +16 -0
- data/lib/pwn/config.rb +145 -7
- data/lib/pwn/cron.rb +210 -0
- data/lib/pwn/driver.rb +9 -0
- data/lib/pwn/memory.rb +136 -0
- data/lib/pwn/plugins/repl.rb +393 -50
- data/lib/pwn/plugins/xxd.rb +24 -0
- data/lib/pwn/sessions.rb +160 -0
- data/lib/pwn/version.rb +1 -1
- data/lib/pwn.rb +4 -0
- data/pwn.gemspec +6 -4
- data/spec/lib/pwn/ai/anthropic_spec.rb +15 -0
- data/spec/lib/pwn/bounty/lifecycle_authz_replay_spec.rb +53 -0
- data/spec/lib/pwn/bounty_spec.rb +10 -0
- data/spec/lib/pwn/cron_spec.rb +15 -0
- data/spec/lib/pwn/memory_spec.rb +24 -0
- data/spec/lib/pwn/sessions_spec.rb +25 -0
- data/spec/smoke/lifecycle_authz_replay_smoke_test.rb +59 -0
- data/third_party/pwn_rdoc.jsonl +69 -2
- data/upgrade_pwn.sh +1 -1
- data/vagrant/provisioners/pwn.sh +1 -1
- metadata +50 -36
data/lib/pwn/config.rb
CHANGED
|
@@ -32,11 +32,20 @@ module PWN
|
|
|
32
32
|
introspection: false,
|
|
33
33
|
grok: {
|
|
34
34
|
base_uri: 'optional - Base URI for Grok - Use private base OR defaults to https://api.x.ai/v1',
|
|
35
|
-
key: 'required -
|
|
35
|
+
key: 'required - xAI Grok API Key',
|
|
36
36
|
model: 'optional - Grok model to use',
|
|
37
|
-
system_role_content: 'You are an ethically hacking
|
|
38
|
-
temp: 'optional -
|
|
39
|
-
max_prompt_length: 256_000
|
|
37
|
+
system_role_content: 'You are an ethically hacking xAI Grok agent.',
|
|
38
|
+
temp: 'optional - Grok temperature',
|
|
39
|
+
max_prompt_length: 256_000,
|
|
40
|
+
# OAuth support for xAI SuperGrok subscriptions (in addition to API key)
|
|
41
|
+
# Populate via pwn-vault command (values stored encrypted in ~/.pwn/pwn.yaml)
|
|
42
|
+
oauth: {
|
|
43
|
+
client_id: 'optional - xAI SuperGrok OAuth Client ID (for subscriptions without API key)',
|
|
44
|
+
client_secret: 'optional - xAI SuperGrok OAuth Client Secret',
|
|
45
|
+
access_token: 'optional - xAI SuperGrok OAuth Access Token (preferred for SuperGrok subs; used as Bearer)',
|
|
46
|
+
refresh_token: 'optional - xAI SuperGrok OAuth Refresh Token',
|
|
47
|
+
token_uri: 'optional - OAuth token endpoint (defaults handled in PWN::AI::Grok if needed)'
|
|
48
|
+
}
|
|
40
49
|
},
|
|
41
50
|
openai: {
|
|
42
51
|
base_uri: 'optional - Base URI for OpenAI - Use private base OR defaults to https://api.openai.com/v1',
|
|
@@ -53,6 +62,14 @@ module PWN
|
|
|
53
62
|
system_role_content: 'You are an ethically hacking Ollama agent.',
|
|
54
63
|
temp: 'optional - Ollama temperature',
|
|
55
64
|
max_prompt_length: 32_000
|
|
65
|
+
},
|
|
66
|
+
anthropic: {
|
|
67
|
+
base_uri: 'optional - Base URI for Anthropic - Use private base OR defaults to https://api.anthropic.com/v1',
|
|
68
|
+
key: 'required - Anthropic API Key',
|
|
69
|
+
model: 'optional - Anthropic model to use (e.g. claude-3-5-sonnet-20240620)',
|
|
70
|
+
system_role_content: 'You are an ethically hacking Anthropic agent.',
|
|
71
|
+
temp: 'optional - Anthropic temperature',
|
|
72
|
+
max_prompt_length: 200_000
|
|
56
73
|
}
|
|
57
74
|
},
|
|
58
75
|
plugins: {
|
|
@@ -125,15 +142,33 @@ module PWN
|
|
|
125
142
|
}
|
|
126
143
|
},
|
|
127
144
|
shodan: { api_key: 'SHODAN API Key' }
|
|
145
|
+
},
|
|
146
|
+
memory: {
|
|
147
|
+
enabled: true,
|
|
148
|
+
provider: 'file' # file | sqlite (future)
|
|
149
|
+
},
|
|
150
|
+
sessions: {
|
|
151
|
+
enabled: true,
|
|
152
|
+
provider: 'jsonl'
|
|
153
|
+
},
|
|
154
|
+
cron: {
|
|
155
|
+
enabled: true,
|
|
156
|
+
provider: 'yaml'
|
|
128
157
|
}
|
|
129
158
|
}
|
|
130
159
|
|
|
131
160
|
# Remove beginning colon from key names
|
|
161
|
+
|
|
132
162
|
yaml_env = YAML.dump(env).gsub(/^(\s*):/, '\1')
|
|
133
163
|
File.write(pwn_env_path, yaml_env)
|
|
134
164
|
# Change file permission to 600
|
|
135
165
|
File.chmod(0o600, pwn_env_path)
|
|
136
166
|
|
|
167
|
+
# Ensure skills dir for pwn-ai agent (in parent of pwn_env_path)
|
|
168
|
+
pwn_env_root = File.dirname(pwn_env_path)
|
|
169
|
+
pwn_skills_path = File.join(pwn_env_root, 'skills')
|
|
170
|
+
FileUtils.mkdir_p(pwn_skills_path)
|
|
171
|
+
|
|
137
172
|
env[:driver_opts] = {
|
|
138
173
|
pwn_env_path: pwn_env_path,
|
|
139
174
|
pwn_dec_path: pwn_dec_path
|
|
@@ -145,7 +180,16 @@ module PWN
|
|
|
145
180
|
)
|
|
146
181
|
|
|
147
182
|
Pry.config.refresh_pwn_env = false if defined?(Pry)
|
|
183
|
+
env[:pwn_skills_path] = pwn_skills_path
|
|
184
|
+
PWN::Config.load_skills(pwn_skills_path: pwn_skills_path)
|
|
185
|
+
|
|
186
|
+
# Hermes-equivalent memory/sessions/cron paths (pwn-ai agent)
|
|
187
|
+
env[:pwn_memory_path] = PWN::Memory::MEMORY_FILE if defined?(PWN::Memory)
|
|
188
|
+
env[:pwn_sessions_path] = PWN::Sessions.sessions_dir if defined?(PWN::Sessions)
|
|
189
|
+
env[:pwn_cron_path] = PWN::Cron.cron_dir if defined?(PWN::Cron)
|
|
190
|
+
|
|
148
191
|
PWN.send(:remove_const, :Env) if PWN.const_defined?(:Env)
|
|
192
|
+
|
|
149
193
|
PWN.const_set(:Env, env.freeze)
|
|
150
194
|
rescue StandardError => e
|
|
151
195
|
raise e
|
|
@@ -195,9 +239,13 @@ module PWN
|
|
|
195
239
|
|
|
196
240
|
public_class_method def self.refresh_env(opts = {})
|
|
197
241
|
pwn_env_root = "#{Dir.home}/.pwn"
|
|
242
|
+
pwn_env_path = opts[:pwn_env_path] ||= "#{pwn_env_root}/pwn.yaml"
|
|
243
|
+
pwn_env_root = File.dirname(pwn_env_path)
|
|
198
244
|
FileUtils.mkdir_p(pwn_env_root)
|
|
199
245
|
|
|
200
|
-
|
|
246
|
+
pwn_skills_path = File.join(pwn_env_root, 'skills')
|
|
247
|
+
FileUtils.mkdir_p(pwn_skills_path)
|
|
248
|
+
|
|
201
249
|
return default_env(pwn_env_path: pwn_env_path) unless File.exist?(pwn_env_path)
|
|
202
250
|
|
|
203
251
|
is_encrypted = PWN::Plugins::Vault.file_encrypted?(file: pwn_env_path)
|
|
@@ -226,9 +274,14 @@ module PWN
|
|
|
226
274
|
raise "ERROR: Unsupported AI Engine: #{engine} in #{pwn_env_path}. Supported AI Engines:\n#{valid_ai_engines.inspect}" unless valid_ai_engines.include?(engine)
|
|
227
275
|
|
|
228
276
|
key = env[:ai][engine][:key]
|
|
229
|
-
|
|
277
|
+
oauth_access = nil
|
|
278
|
+
if engine == :grok
|
|
279
|
+
oauth = env[:ai][engine][:oauth] ||= {}
|
|
280
|
+
oauth_access = oauth[:access_token] if oauth[:access_token] && !oauth[:access_token].to_s.match?(/optional/i) && !oauth[:access_token].to_s.empty?
|
|
281
|
+
end
|
|
282
|
+
if key.nil? && oauth_access.nil?
|
|
230
283
|
key = PWN::Plugins::AuthenticationHelper.mask_password(
|
|
231
|
-
prompt: "#{engine} API Key"
|
|
284
|
+
prompt: "#{engine} API Key (or configure oauth:access_token in pwn-vault for xAI SuperGrok subscriptions)"
|
|
232
285
|
)
|
|
233
286
|
env[:ai][engine][:key] = key
|
|
234
287
|
end
|
|
@@ -256,11 +309,23 @@ module PWN
|
|
|
256
309
|
pwn_dec_path: pwn_dec_path
|
|
257
310
|
}
|
|
258
311
|
|
|
312
|
+
# Make pwn-ai (Hermes agent TUI equiv) aware of skills folder in pwn_env parent (before freeze)
|
|
313
|
+
env[:pwn_skills_path] = pwn_skills_path if defined?(pwn_skills_path)
|
|
314
|
+
PWN::Config.load_skills(pwn_skills_path: pwn_skills_path) if defined?(pwn_skills_path)
|
|
315
|
+
|
|
316
|
+
# Hermes-equivalent: memory, sessions, cron for pwn-ai agent (before freeze)
|
|
317
|
+
env[:pwn_memory_path] = PWN::Memory::MEMORY_FILE if defined?(PWN::Memory)
|
|
318
|
+
PWN::Memory.load if defined?(PWN::Memory)
|
|
319
|
+
env[:pwn_sessions_path] = PWN::Sessions.sessions_dir if defined?(PWN::Sessions)
|
|
320
|
+
env[:pwn_cron_path] = PWN::Cron.cron_dir if defined?(PWN::Cron)
|
|
321
|
+
|
|
259
322
|
# Assign the refreshed env to PWN::Env
|
|
323
|
+
|
|
260
324
|
PWN.send(:remove_const, :Env) if PWN.const_defined?(:Env)
|
|
261
325
|
PWN.const_set(:Env, env.freeze)
|
|
262
326
|
|
|
263
327
|
# Redact sensitive artifacts from PWN::Env and store in PWN::EnvRedacted
|
|
328
|
+
|
|
264
329
|
env_redacted = redact_sensitive_artifacts(config: env)
|
|
265
330
|
PWN.send(:remove_const, :EnvRedacted) if PWN.const_defined?(:EnvRedacted)
|
|
266
331
|
PWN.const_set(:EnvRedacted, env_redacted.freeze)
|
|
@@ -272,6 +337,79 @@ module PWN
|
|
|
272
337
|
raise e
|
|
273
338
|
end
|
|
274
339
|
|
|
340
|
+
# Supported Method Parameters::
|
|
341
|
+
# pwn_skills_path = PWN::Config.pwn_skills_path(
|
|
342
|
+
# pwn_env_path: 'optional - Path to pwn.yaml file. Defaults to ~/.pwn/pwn.yaml'
|
|
343
|
+
# )
|
|
344
|
+
public_class_method def self.pwn_skills_path(opts = {})
|
|
345
|
+
pwn_env_path = opts[:pwn_env_path] ||= "#{Dir.home}/.pwn/pwn.yaml"
|
|
346
|
+
File.join(File.dirname(pwn_env_path), 'skills')
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Supported Method Parameters::
|
|
350
|
+
# skills = PWN::Config.load_skills(
|
|
351
|
+
# pwn_skills_path: 'optional - Path to skills folder. Defaults to ~/.pwn/skills'
|
|
352
|
+
# )
|
|
353
|
+
#
|
|
354
|
+
# Loads instruction-based skills (.md, .txt, .skill, .yaml) and executable Ruby skills (.rb)
|
|
355
|
+
# into PWN::Skills constant (hash of basename => {type, path, content, loaded?}).
|
|
356
|
+
# The pwn-ai command (REPL driver) loads and is aware of this folder to expand
|
|
357
|
+
# autonomous agent capabilities (equivalent to Hermes agent TUI skills for task execution).
|
|
358
|
+
public_class_method def self.load_skills(opts = {})
|
|
359
|
+
pwn_skills_path = opts[:pwn_skills_path] || PWN::Env[:pwn_skills_path] || pwn_skills_path
|
|
360
|
+
FileUtils.mkdir_p(pwn_skills_path) if pwn_skills_path && !Dir.exist?(pwn_skills_path.to_s)
|
|
361
|
+
|
|
362
|
+
skills = {}
|
|
363
|
+
return skills unless pwn_skills_path && Dir.exist?(pwn_skills_path.to_s)
|
|
364
|
+
|
|
365
|
+
Dir.glob(File.join(pwn_skills_path, '*.{rb,md,txt,skill,yml,yaml}')).each do |skill_file|
|
|
366
|
+
basename = File.basename(skill_file, '.*').to_sym
|
|
367
|
+
content = File.read(skill_file)
|
|
368
|
+
ext = File.extname(skill_file).downcase
|
|
369
|
+
|
|
370
|
+
if ext == '.rb'
|
|
371
|
+
begin
|
|
372
|
+
require skill_file
|
|
373
|
+
skills[basename] = { type: :ruby, path: skill_file, content: content, loaded: true }
|
|
374
|
+
rescue StandardError => e
|
|
375
|
+
skills[basename] = { type: :ruby, path: skill_file, content: content, loaded: false, error: e.message }
|
|
376
|
+
end
|
|
377
|
+
else
|
|
378
|
+
skills[basename] = { type: :instruction, path: skill_file, content: content }
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
PWN.send(:remove_const, :Skills) if PWN.const_defined?(:Skills)
|
|
383
|
+
PWN.const_set(:Skills, skills.freeze)
|
|
384
|
+
skills
|
|
385
|
+
rescue StandardError => e
|
|
386
|
+
raise e
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Supported Method Parameters::
|
|
390
|
+
# path = PWN::Config.pwn_memory_path
|
|
391
|
+
public_class_method def self.pwn_memory_path
|
|
392
|
+
defined?(PWN::Memory) ? PWN::Memory::MEMORY_FILE : File.join(Dir.home, '.pwn', 'memory.json')
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Supported Method Parameters::
|
|
396
|
+
# PWN::Config.load_memory
|
|
397
|
+
public_class_method def self.load_memory
|
|
398
|
+
defined?(PWN::Memory) ? PWN::Memory.load : {}
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# Supported Method Parameters::
|
|
402
|
+
# path = PWN::Config.pwn_sessions_path
|
|
403
|
+
public_class_method def self.pwn_sessions_path
|
|
404
|
+
defined?(PWN::Sessions) ? PWN::Sessions.sessions_dir : File.join(Dir.home, '.pwn', 'sessions')
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# Supported Method Parameters::
|
|
408
|
+
# path = PWN::Config.pwn_cron_path
|
|
409
|
+
public_class_method def self.pwn_cron_path
|
|
410
|
+
defined?(PWN::Cron) ? PWN::Cron.cron_dir : File.join(Dir.home, '.pwn', 'cron')
|
|
411
|
+
end
|
|
412
|
+
|
|
275
413
|
# Author(s):: 0day Inc. <support@0dayinc.com>
|
|
276
414
|
|
|
277
415
|
public_class_method def self.authors
|
data/lib/pwn/cron.rb
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'time'
|
|
6
|
+
require 'securerandom'
|
|
7
|
+
|
|
8
|
+
module PWN
|
|
9
|
+
# PWN::Cron provides cron / scheduled task management for the pwn-ai agent
|
|
10
|
+
# (equivalent to Hermes cron jobs + scheduler).
|
|
11
|
+
# Jobs are defined in ~/.pwn/cron/jobs.yml and can be triggered by system
|
|
12
|
+
# cron, manual run, or from within pwn-ai agent loops.
|
|
13
|
+
#
|
|
14
|
+
# Each job can contain a prompt (for pwn-ai), a ruby script snippet, or
|
|
15
|
+
# reference to external script. Delivery can be 'log' (default), 'email', etc.
|
|
16
|
+
# (email would require additional plugins).
|
|
17
|
+
module Cron
|
|
18
|
+
CRON_DIR = File.join(Dir.home, '.pwn', 'cron')
|
|
19
|
+
JOBS_FILE = File.join(CRON_DIR, 'jobs.yml')
|
|
20
|
+
|
|
21
|
+
# Supported Method Parameters::
|
|
22
|
+
# dir = PWN::Cron.cron_dir
|
|
23
|
+
public_class_method def self.cron_dir
|
|
24
|
+
FileUtils.mkdir_p(CRON_DIR)
|
|
25
|
+
CRON_DIR
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Supported Method Parameters::
|
|
29
|
+
# jobs = PWN::Cron.list
|
|
30
|
+
public_class_method def self.list
|
|
31
|
+
load_jobs
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Supported Method Parameters::
|
|
35
|
+
# job = PWN::Cron.create(
|
|
36
|
+
# name: 'optional',
|
|
37
|
+
# schedule: 'required e.g. "0 * * * *" or "30m" or "every 2h"',
|
|
38
|
+
# prompt: 'optional - pwn-ai prompt to run',
|
|
39
|
+
# ruby: 'optional - ruby snippet to eval',
|
|
40
|
+
# script: 'optional - path to external script',
|
|
41
|
+
# delivery: 'log|stdout (default log)',
|
|
42
|
+
# enabled: true
|
|
43
|
+
# )
|
|
44
|
+
public_class_method def self.create(opts = {})
|
|
45
|
+
jobs = load_jobs
|
|
46
|
+
id = SecureRandom.hex(6)
|
|
47
|
+
name = opts[:name] || "job-#{id}"
|
|
48
|
+
job = {
|
|
49
|
+
id: id,
|
|
50
|
+
name: name,
|
|
51
|
+
schedule: opts[:schedule] || '0 * * * *',
|
|
52
|
+
prompt: opts[:prompt],
|
|
53
|
+
ruby: opts[:ruby],
|
|
54
|
+
script: opts[:script],
|
|
55
|
+
delivery: opts[:delivery] || 'log',
|
|
56
|
+
enabled: opts.fetch(:enabled, true),
|
|
57
|
+
created_at: Time.now.utc.iso8601,
|
|
58
|
+
last_run: nil,
|
|
59
|
+
last_status: nil
|
|
60
|
+
}
|
|
61
|
+
jobs[id] = job
|
|
62
|
+
save_jobs(jobs)
|
|
63
|
+
|
|
64
|
+
# Optionally install a crontab entry (user must have permission)
|
|
65
|
+
install_crontab_entry(job) if opts[:install_crontab]
|
|
66
|
+
|
|
67
|
+
job
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Supported Method Parameters::
|
|
71
|
+
# PWN::Cron.run(id: 'required or name')
|
|
72
|
+
# Executes the job (for pwn-ai prompt it will use current active AI engine
|
|
73
|
+
# via PWN::AI::* but without full REPL hook unless in pwn-ai).
|
|
74
|
+
public_class_method def self.run(opts = {})
|
|
75
|
+
id = opts[:id]
|
|
76
|
+
jobs = load_jobs
|
|
77
|
+
job = jobs[id] || jobs.values.find { |j| j[:name] == id || j[:id] == id }
|
|
78
|
+
raise "Job #{id} not found" unless job
|
|
79
|
+
|
|
80
|
+
start = Time.now
|
|
81
|
+
result = nil
|
|
82
|
+
status = 'success'
|
|
83
|
+
|
|
84
|
+
begin
|
|
85
|
+
if job[:prompt]
|
|
86
|
+
engine = begin
|
|
87
|
+
PWN::Env[:ai][:active].to_s.downcase.to_sym
|
|
88
|
+
rescue StandardError
|
|
89
|
+
:grok
|
|
90
|
+
end
|
|
91
|
+
case engine
|
|
92
|
+
when :grok
|
|
93
|
+
result = PWN::AI::Grok.chat(request: job[:prompt], spinner: false)
|
|
94
|
+
when :ollama
|
|
95
|
+
result = PWN::AI::Ollama.chat(request: job[:prompt], spinner: false)
|
|
96
|
+
when :openai
|
|
97
|
+
result = PWN::AI::OpenAI.chat(request: job[:prompt], spinner: false)
|
|
98
|
+
when :anthropic
|
|
99
|
+
result = PWN::AI::Anthropic.chat(request: job[:prompt], spinner: false)
|
|
100
|
+
end
|
|
101
|
+
result = begin
|
|
102
|
+
result[:choices].last[:content]
|
|
103
|
+
rescue StandardError
|
|
104
|
+
result.to_s
|
|
105
|
+
end
|
|
106
|
+
elsif job[:ruby]
|
|
107
|
+
result = eval(job[:ruby], TOPLEVEL_BINDING) # rubocop:disable Security/Eval
|
|
108
|
+
elsif job[:script] && File.exist?(job[:script])
|
|
109
|
+
result = `#{job[:script]} 2>&1`
|
|
110
|
+
else
|
|
111
|
+
result = 'No prompt/ruby/script defined'
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
if job[:delivery] == 'log'
|
|
115
|
+
log_path = File.join(cron_dir, "#{job[:id]}.log")
|
|
116
|
+
File.open(log_path, 'a') do |f|
|
|
117
|
+
f.puts("[#{Time.now}] RUN #{job[:name]} (#{job[:id]})\n#{result}\n---")
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
rescue StandardError => e
|
|
121
|
+
status = 'error'
|
|
122
|
+
result = "ERROR: #{e.class} - #{e.message}\n#{e.backtrace.first(5).join("\n")}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
job[:last_run] = Time.now.utc.iso8601
|
|
126
|
+
job[:last_status] = status
|
|
127
|
+
jobs[job[:id]] = job
|
|
128
|
+
save_jobs(jobs)
|
|
129
|
+
|
|
130
|
+
{ job: job, result: result, duration: Time.now - start, status: status }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Supported Method Parameters::
|
|
134
|
+
# PWN::Cron.remove(id:)
|
|
135
|
+
public_class_method def self.remove(opts = {}) # rubocop:disable Naming/PredicateMethod
|
|
136
|
+
id = opts[:id]
|
|
137
|
+
jobs = load_jobs
|
|
138
|
+
jobs.delete(id)
|
|
139
|
+
save_jobs(jobs)
|
|
140
|
+
true
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Supported Method Parameters::
|
|
144
|
+
# PWN::Cron.enable/disable(id:)
|
|
145
|
+
public_class_method def self.enable(opts = {})
|
|
146
|
+
toggle(opts[:id], true)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
public_class_method def self.disable(opts = {})
|
|
150
|
+
toggle(opts[:id], false)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Install a crontab line that invokes this job via pwn
|
|
154
|
+
# (assumes /opt/pwn and rvm ruby-4.0.1@pwn - user can edit crontab)
|
|
155
|
+
public_class_method def self.install_crontab_entry(job)
|
|
156
|
+
cron_line = "#{job[:schedule]} cd /opt/pwn && /usr/local/rvm/bin/rvm ruby-4.0.1@pwn do ruby -I lib -e 'require \"pwn\"; PWN::Cron.run(id: \"#{job[:id]}\")' >> #{File.join(cron_dir, 'cron.log')} 2>&1"
|
|
157
|
+
# Append to user's crontab (non-destructive)
|
|
158
|
+
existing = `crontab -l 2>/dev/null || true`
|
|
159
|
+
unless existing.include?(job[:id])
|
|
160
|
+
new_cron = existing + "\n# pwn-cron #{job[:name]} (#{job[:id]})\n#{cron_line}\n"
|
|
161
|
+
IO.popen('crontab -', 'w') { |io| io.write(new_cron) }
|
|
162
|
+
end
|
|
163
|
+
cron_line
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private_class_method def self.load_jobs
|
|
167
|
+
FileUtils.mkdir_p(cron_dir)
|
|
168
|
+
return {} unless File.exist?(JOBS_FILE)
|
|
169
|
+
|
|
170
|
+
YAML.safe_load_file(JOBS_FILE, symbolize_names: true) || {}
|
|
171
|
+
rescue StandardError
|
|
172
|
+
{}
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private_class_method def self.save_jobs(jobs)
|
|
176
|
+
File.write(JOBS_FILE, YAML.dump(jobs))
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
private_class_method def self.toggle(id, enabled)
|
|
180
|
+
jobs = load_jobs
|
|
181
|
+
if jobs[id]
|
|
182
|
+
jobs[id][:enabled] = enabled
|
|
183
|
+
save_jobs(jobs)
|
|
184
|
+
end
|
|
185
|
+
jobs[id]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Author(s):: 0day Inc. <support@0dayinc.com>
|
|
189
|
+
|
|
190
|
+
public_class_method def self.authors
|
|
191
|
+
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Display Usage for this Module
|
|
195
|
+
public_class_method def self.help
|
|
196
|
+
puts <<~USAGE
|
|
197
|
+
USAGE:
|
|
198
|
+
PWN::Cron.create(schedule: '0 * * * *', prompt: 'Run daily recon on target.com using NmapIt and report', name: 'daily-recon')
|
|
199
|
+
PWN::Cron.list
|
|
200
|
+
res = PWN::Cron.run(id: 'abc123')
|
|
201
|
+
PWN::Cron.enable(id: 'abc123')
|
|
202
|
+
PWN::Cron.disable(id: 'abc123')
|
|
203
|
+
PWN::Cron.remove(id: 'abc123')
|
|
204
|
+
# To have system cron call it, use install_crontab_entry or the :install_crontab option on create
|
|
205
|
+
|
|
206
|
+
#{self}.authors
|
|
207
|
+
USAGE
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
data/lib/pwn/driver.rb
CHANGED
data/lib/pwn/memory.rb
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'time'
|
|
6
|
+
|
|
7
|
+
module PWN
|
|
8
|
+
# PWN::Memory provides persistent cross-session memory for the pwn-ai agent
|
|
9
|
+
# (equivalent to Hermes agent memory providers). Facts, user preferences,
|
|
10
|
+
# environment details, lessons learned, and task state are stored in
|
|
11
|
+
# ~/.pwn/memory.json and survive across REPL restarts / pwn-ai sessions.
|
|
12
|
+
#
|
|
13
|
+
# The pwn-ai agent (in agent mode) automatically receives relevant memory
|
|
14
|
+
# injected into its system prompt. The agent can also call remember/recall
|
|
15
|
+
# via ruby code blocks during execution loops.
|
|
16
|
+
module Memory
|
|
17
|
+
MEMORY_FILE = File.join(Dir.home, '.pwn', 'memory.json')
|
|
18
|
+
|
|
19
|
+
# Supported Method Parameters::
|
|
20
|
+
# memory = PWN::Memory.load
|
|
21
|
+
public_class_method def self.load
|
|
22
|
+
FileUtils.mkdir_p(File.dirname(MEMORY_FILE))
|
|
23
|
+
return {} unless File.exist?(MEMORY_FILE)
|
|
24
|
+
|
|
25
|
+
JSON.parse(File.read(MEMORY_FILE), symbolize_names: true)
|
|
26
|
+
rescue StandardError
|
|
27
|
+
{}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Supported Method Parameters::
|
|
31
|
+
# PWN::Memory.save(memory_hash)
|
|
32
|
+
public_class_method def self.save(mem = {})
|
|
33
|
+
FileUtils.mkdir_p(File.dirname(MEMORY_FILE))
|
|
34
|
+
File.write(MEMORY_FILE, JSON.pretty_generate(mem))
|
|
35
|
+
mem
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Supported Method Parameters::
|
|
39
|
+
# PWN::Memory.remember(
|
|
40
|
+
# key: 'required - Symbol or String key for the memory fact',
|
|
41
|
+
# value: 'required - The value (any JSON serializable)',
|
|
42
|
+
# category: 'optional - e.g. :fact, :preference, :lesson, :env (default: :fact)'
|
|
43
|
+
# )
|
|
44
|
+
public_class_method def self.remember(opts = {})
|
|
45
|
+
key = opts[:key]
|
|
46
|
+
value = opts[:value]
|
|
47
|
+
category = opts[:category] || :fact
|
|
48
|
+
|
|
49
|
+
raise 'ERROR: key and value are required' if key.nil? || value.nil?
|
|
50
|
+
|
|
51
|
+
mem = load
|
|
52
|
+
mem[key.to_sym] = {
|
|
53
|
+
value: value,
|
|
54
|
+
category: category.to_sym,
|
|
55
|
+
timestamp: Time.now.utc.iso8601,
|
|
56
|
+
source: 'pwn-ai'
|
|
57
|
+
}
|
|
58
|
+
save(mem)
|
|
59
|
+
mem[key.to_sym]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Supported Method Parameters::
|
|
63
|
+
# results = PWN::Memory.recall(
|
|
64
|
+
# query: 'optional - string to search keys/values/categories (simple match)',
|
|
65
|
+
# category: 'optional - filter by category',
|
|
66
|
+
# limit: 'optional - max results (default 50)'
|
|
67
|
+
# )
|
|
68
|
+
public_class_method def self.recall(opts = {})
|
|
69
|
+
query = opts[:query].to_s.downcase
|
|
70
|
+
category = opts[:category]
|
|
71
|
+
limit = opts[:limit] || 50
|
|
72
|
+
|
|
73
|
+
mem = load
|
|
74
|
+
results = mem.select do |k, v|
|
|
75
|
+
match = true
|
|
76
|
+
match &&= k.to_s.downcase.include?(query) || v[:value].to_s.downcase.include?(query) || v[:category].to_s.downcase.include?(query) if query && !query.empty?
|
|
77
|
+
match &&= (v[:category] == category.to_sym) if category
|
|
78
|
+
match
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
results.to_a.first(limit).to_h
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Supported Method Parameters::
|
|
85
|
+
# PWN::Memory.forget(key)
|
|
86
|
+
public_class_method def self.forget(key) # rubocop:disable Naming/PredicateMethod
|
|
87
|
+
mem = load
|
|
88
|
+
mem.delete(key.to_sym)
|
|
89
|
+
save(mem)
|
|
90
|
+
true
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Supported Method Parameters::
|
|
94
|
+
# PWN::Memory.clear
|
|
95
|
+
public_class_method def self.clear
|
|
96
|
+
FileUtils.rm_f(MEMORY_FILE)
|
|
97
|
+
{}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Supported Method Parameters::
|
|
101
|
+
# context = PWN::Memory.to_context(limit: 20)
|
|
102
|
+
# (used internally by pwn-ai hook to inject into system prompt)
|
|
103
|
+
public_class_method def self.to_context(opts = {})
|
|
104
|
+
limit = opts[:limit] || 20
|
|
105
|
+
mem = recall(limit: limit)
|
|
106
|
+
return '' if mem.empty?
|
|
107
|
+
|
|
108
|
+
ctx = "\n\nPERSISTENT MEMORY (cross-session facts, prefs, lessons - use PWN::Memory.remember to store new ones):\n"
|
|
109
|
+
mem.each do |k, v|
|
|
110
|
+
ctx += "- #{k} [#{v[:category]} @ #{v[:timestamp]}]: #{v[:value].to_s[0, 300]}\n"
|
|
111
|
+
end
|
|
112
|
+
ctx
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Author(s):: 0day Inc. <support@0dayinc.com>
|
|
116
|
+
|
|
117
|
+
public_class_method def self.authors
|
|
118
|
+
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Display Usage for this Module
|
|
122
|
+
public_class_method def self.help
|
|
123
|
+
puts <<~USAGE
|
|
124
|
+
USAGE:
|
|
125
|
+
mem = PWN::Memory.load
|
|
126
|
+
PWN::Memory.remember(key: :user_prefers_ruby, value: 'Always prefer pure Ruby + RestClient patterns', category: :preference)
|
|
127
|
+
facts = PWN::Memory.recall(query: 'recon', category: :fact, limit: 10)
|
|
128
|
+
PWN::Memory.forget(:some_key)
|
|
129
|
+
PWN::Memory.clear
|
|
130
|
+
context_str = PWN::Memory.to_context
|
|
131
|
+
|
|
132
|
+
#{self}.authors
|
|
133
|
+
USAGE
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|