pwn 0.5.782 → 0.5.784
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/Gemfile +3 -3
- data/Rakefile +19 -1
- data/bin/pwn_setup +1 -1
- data/build_gem.sh +1 -1
- data/documentation/Backup-Retention.md +27 -0
- data/documentation/Cron.md +64 -35
- data/documentation/How-PWN-Works.md +1 -1
- data/documentation/Installation.md +280 -15
- data/documentation/Persistence.md +1 -1
- data/documentation/Reinforcement-Learning.md +5 -6
- data/documentation/diagrams/cron-scheduling.svg +130 -128
- data/documentation/diagrams/dot/cron-scheduling.dot +3 -3
- data/documentation/pwn-REPL.md +31 -3
- data/documentation/pwn-ai-Agent.md +62 -2
- data/etc/default_cron/session_cleanup.rb +1544 -44
- data/lib/pwn/ai/agent/curriculum.rb +2 -2
- data/lib/pwn/ai/agent/extrospection.rb +6 -7
- data/lib/pwn/ai/agent/learning.rb +59 -11
- data/lib/pwn/ai/agent/mistakes.rb +22 -3
- data/lib/pwn/ai/agent/policy.rb +5 -0
- data/lib/pwn/ai/agent/swarm.rb +31 -16
- data/lib/pwn/ai/agent/tools/sessions.rb +6 -6
- data/lib/pwn/ai/http_retry.rb +3 -0
- data/lib/pwn/ai/router.rb +48 -3
- data/lib/pwn/cron.rb +120 -49
- data/lib/pwn/memory.rb +62 -6
- data/lib/pwn/migrate.rb +292 -15
- data/lib/pwn/plugins/binary_parser.rb +5 -4
- data/lib/pwn/plugins/ghidra.rb +4 -3
- data/lib/pwn/plugins/log.rb +18 -2
- data/lib/pwn/plugins/repl/ai.rb +25 -4
- data/lib/pwn/plugins/repl/ai_console.rb +41 -21
- data/lib/pwn/plugins/repl/ai_swarm.rb +4 -4
- data/lib/pwn/session_trace.rb +1 -1
- data/lib/pwn/sessions.rb +359 -92
- data/lib/pwn/setup.rb +9 -17
- data/lib/pwn/version.rb +1 -1
- data/scripts/build_gate.rb +58 -0
- data/spec/integration/build_gate_spec.rb +73 -0
- data/spec/integration/persistence_roundtrip_spec.rb +1 -7
- data/spec/lib/pwn/ai/agent/learning_spec.rb +65 -2
- data/spec/lib/pwn/ai/agent/policy_spec.rb +27 -0
- data/spec/lib/pwn/ai/agent/swarm_spec.rb +3 -3
- data/spec/lib/pwn/ai/router_spec.rb +30 -2
- data/spec/lib/pwn/cron_spec.rb +113 -6
- data/spec/lib/pwn/memory_spec.rb +58 -1
- data/spec/lib/pwn/migrate_spec.rb +160 -5
- data/spec/lib/pwn/plugins/log_spec.rb +7 -0
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +203 -6
- data/spec/lib/pwn/plugins/repl/ai_result_chronology_spec.rb +2 -2
- data/spec/lib/pwn/plugins/repl/ai_spec.rb +28 -11
- data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +11 -0
- data/spec/lib/pwn/redaction_spec.rb +13 -10
- data/spec/lib/pwn/session_cleanup_concurrency_spec.rb +201 -0
- data/spec/lib/pwn/session_cleanup_default_spec.rb +166 -3
- data/spec/lib/pwn/session_cleanup_legacy_spec.rb +101 -0
- data/spec/lib/pwn/session_cleanup_payload_spec.rb +800 -36
- data/spec/lib/pwn/session_cleanup_recovery_spec.rb +389 -0
- data/spec/lib/pwn/sessions_spec.rb +228 -16
- data/spec/lib/pwn/setup_spec.rb +3 -1
- data/spec/support/gate_failure_formatter.rb +14 -0
- data/third_party/pwn_rdoc.jsonl +27 -1
- metadata +14 -7
data/lib/pwn/migrate.rb
CHANGED
|
@@ -4,6 +4,9 @@ require 'json'
|
|
|
4
4
|
require 'yaml'
|
|
5
5
|
require 'fileutils'
|
|
6
6
|
require 'time'
|
|
7
|
+
require 'digest'
|
|
8
|
+
require 'find'
|
|
9
|
+
require 'securerandom'
|
|
7
10
|
|
|
8
11
|
module PWN
|
|
9
12
|
# PWN::Migrate — the ~/.pwn state **doctor** and **auto-migrator**.
|
|
@@ -49,7 +52,7 @@ module PWN
|
|
|
49
52
|
# Bump this whenever the shape of any file under ~/.pwn changes in a
|
|
50
53
|
# way that requires a one-time transform. Add the transform as an
|
|
51
54
|
# entry in MIGRATIONS keyed by the NEW schema number.
|
|
52
|
-
SCHEMA_VERSION =
|
|
55
|
+
SCHEMA_VERSION = 11
|
|
53
56
|
|
|
54
57
|
OK = "\e[32mok\e[0m"
|
|
55
58
|
BAD = "\e[31mFAIL\e[0m"
|
|
@@ -269,6 +272,10 @@ module PWN
|
|
|
269
272
|
10 => lambda { |_root, io|
|
|
270
273
|
io.puts ' · pwn.yaml ai reasoning_effort for anthropic, gemini, ollama, openwebui'
|
|
271
274
|
PWN::Migrate.backfill_vault(io: io)
|
|
275
|
+
},
|
|
276
|
+
11 => lambda { |_root, io|
|
|
277
|
+
io.puts ' · versioned housekeeping policy and managed session_cleanup helper'
|
|
278
|
+
PWN::Cron.install_defaults
|
|
272
279
|
}
|
|
273
280
|
}.freeze
|
|
274
281
|
|
|
@@ -383,14 +390,17 @@ module PWN
|
|
|
383
390
|
backup = opts.fetch(:backup, true)
|
|
384
391
|
io = opts[:io] || $stdout
|
|
385
392
|
|
|
386
|
-
FileUtils.mkdir_p(PWN_ROOT)
|
|
393
|
+
FileUtils.mkdir_p(PWN_ROOT) unless dry_run
|
|
387
394
|
before = status
|
|
388
395
|
io.puts "PWN::Migrate — pwn #{before[:pwn_version]} · ~/.pwn schema " \
|
|
389
396
|
"#{before[:schema_installed]} → #{before[:schema_current]}" \
|
|
390
397
|
"#{' (dry-run)' if dry_run}"
|
|
391
398
|
|
|
399
|
+
drift = fix ? vault_drift(key: opts[:key], iv: opts[:iv]) : nil
|
|
400
|
+
changes = before[:schema_installed] < SCHEMA_VERSION ||
|
|
401
|
+
(fix && (!before[:incompatible].empty? || (drift && !drift[:missing].empty?)))
|
|
392
402
|
bpath = nil
|
|
393
|
-
if backup && !dry_run && File.directory?(PWN_ROOT)
|
|
403
|
+
if backup && changes && !dry_run && File.directory?(PWN_ROOT)
|
|
394
404
|
bpath = backup!
|
|
395
405
|
io.puts " backup : #{bpath}"
|
|
396
406
|
end
|
|
@@ -422,7 +432,7 @@ module PWN
|
|
|
422
432
|
fixed << vd if vd && vd[:added].to_i.positive?
|
|
423
433
|
end
|
|
424
434
|
|
|
425
|
-
stamp!
|
|
435
|
+
stamp! if !dry_run && changes
|
|
426
436
|
|
|
427
437
|
after = status
|
|
428
438
|
io.puts
|
|
@@ -612,19 +622,277 @@ module PWN
|
|
|
612
622
|
"dropped #{bad} bad line(s), kept #{good.length}"
|
|
613
623
|
end
|
|
614
624
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
625
|
+
# Supported Method Parameters::
|
|
626
|
+
# PWN::Migrate.prune_backups(dry_run: true, keep: 3, limit: 10, admission: nil)
|
|
627
|
+
# Default duplicate-only pruning is nondestructive to unique history.
|
|
628
|
+
# Explicit expire_history policy bounds recovery generations instead.
|
|
629
|
+
public_class_method def self.prune_backups(opts = {})
|
|
630
|
+
return expire_backup_generations(opts) if opts[:expire_history] || backup_retention_policy['expire_history']
|
|
631
|
+
|
|
632
|
+
dry_run = opts.fetch(:dry_run, true)
|
|
633
|
+
keep = [Integer(opts.fetch(:keep, 3)), 3].max
|
|
634
|
+
limit = Integer(opts.fetch(:limit, 10))
|
|
635
|
+
admission = opts[:admission]
|
|
636
|
+
result = { eligible: [], deleted: [], retained: [], incomplete: false }
|
|
637
|
+
return result unless File.directory?(BACKUP_ROOT)
|
|
638
|
+
raise IOError, 'Unsafe backup root' unless File.realpath(BACKUP_ROOT) == BACKUP_ROOT && File.stat(BACKUP_ROOT).uid == Process.uid
|
|
639
|
+
|
|
640
|
+
work = lambda do
|
|
641
|
+
names = Dir.children(BACKUP_ROOT).grep(/\A\d{8}_\d{6}(?:_[a-f0-9]{8})?\z/).sort
|
|
642
|
+
retained = names.last(keep)
|
|
643
|
+
manifests = retained.to_h { |name| [name, backup_inventory(path: File.join(BACKUP_ROOT, name), admission: admission)] }
|
|
644
|
+
names[0...-keep].to_a.last(limit).reverse_each do |name|
|
|
645
|
+
path = File.join(BACKUP_ROOT, name)
|
|
646
|
+
inventory = backup_inventory(path: path, admission: admission)
|
|
647
|
+
match = manifests.find { |_other, rows| rows == inventory }
|
|
648
|
+
unless match
|
|
649
|
+
result[:retained] << name
|
|
650
|
+
next
|
|
651
|
+
end
|
|
652
|
+
result[:eligible] << name
|
|
653
|
+
next if dry_run
|
|
621
654
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
655
|
+
canonical = File.join(BACKUP_ROOT, match.first)
|
|
656
|
+
raise IOError, 'Backup changed before pruning' unless backup_inventory(path: canonical, admission: admission) == inventory && backup_inventory(path: path, admission: admission) == inventory
|
|
657
|
+
|
|
658
|
+
FileUtils.remove_entry_secure(path)
|
|
659
|
+
raise IOError, 'Backup pruning failed' if File.exist?(path)
|
|
660
|
+
|
|
661
|
+
result[:deleted] << name
|
|
662
|
+
end
|
|
663
|
+
result
|
|
664
|
+
end
|
|
665
|
+
return work.call if dry_run
|
|
666
|
+
|
|
667
|
+
File.open(File.join(BACKUP_ROOT, '.retention.lock'), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock|
|
|
668
|
+
raise IOError, 'Backup maintenance active' unless lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
669
|
+
|
|
670
|
+
work.call
|
|
671
|
+
end
|
|
672
|
+
rescue IOError => e
|
|
673
|
+
result[:incomplete] = true
|
|
674
|
+
result[:error] = e.message
|
|
675
|
+
result
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
# Explicit opt-in expires historical versions, not merely duplicate bytes.
|
|
679
|
+
private_class_method def self.backup_retention_policy
|
|
680
|
+
path = File.join(BACKUP_ROOT, '.retention-policy.json')
|
|
681
|
+
return { 'keep' => 3, 'pins' => [], 'expire_history' => false } unless File.exist?(path) || File.symlink?(path)
|
|
682
|
+
raise IOError, 'Unsafe retention policy' unless File.realpath(path) == path && File.stat(path).uid == Process.uid
|
|
683
|
+
|
|
684
|
+
value = JSON.parse(File.binread(path))
|
|
685
|
+
valid = value['version'] == 1 && [true, false].include?(value['expire_history']) && value['keep'].is_a?(Integer) && value['keep'] >= 3 && value['pins'].is_a?(Array)
|
|
686
|
+
valid &&= value['pins'].all? { |name| name.is_a?(String) && name.match?(/\A\d{8}_\d{6}(?:_[a-f0-9]{8})?\z/) }
|
|
687
|
+
raise IOError, 'Invalid retention policy' unless valid
|
|
688
|
+
|
|
689
|
+
value
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
private_class_method def self.backup_input_names
|
|
693
|
+
(STATE_FILES.keys.map { |rel| rel.delete_suffix('/') } + %w[.schema pwn.yaml.decryptor]).uniq.reject { |rel| %w[sessions swarm].include?(rel.split('/').first) }
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
# Conservative literal-path protection in current evidence/continuation stores.
|
|
697
|
+
# Historical log/history/metrics copies are not operational dependencies.
|
|
698
|
+
private_class_method def self.backup_reference_pins(opts = {})
|
|
699
|
+
admission = opts[:admission]
|
|
700
|
+
pins = []
|
|
701
|
+
roots = %w[learning learning.jsonl memory.json mistakes.json policy.json policy_traj.jsonl preferences.jsonl engagements artifacts sessions swarm jobs runs open_goals open_goal.json curriculum finetune extrospection skill_review.jsonl maintenance]
|
|
702
|
+
roots.each do |name|
|
|
703
|
+
path = File.join(PWN_ROOT, name)
|
|
704
|
+
next unless File.exist?(path) || File.symlink?(path)
|
|
705
|
+
|
|
706
|
+
Find.find(path) do |file|
|
|
707
|
+
stat = File.lstat(file)
|
|
708
|
+
raise IOError, 'Unsafe current evidence entry' unless stat.file? || stat.directory?
|
|
709
|
+
next unless stat.file? && file.match?(/\.jsonl?\z/)
|
|
710
|
+
raise IOError, 'Backup work budget exhausted' if admission && !admission.call(stat.size + 1)
|
|
711
|
+
|
|
712
|
+
documents = file.end_with?('.jsonl') ? File.foreach(file).reject { |line| line.strip.empty? }.map { |line| JSON.parse(line) } : [JSON.parse(File.binread(file))]
|
|
713
|
+
documents.select! { |row| row.is_a?(Hash) && (%w[prepared interrupted].include?(row['state']) || row['pinned'] == true) } if name == 'maintenance'
|
|
714
|
+
until documents.empty?
|
|
715
|
+
value = documents.pop
|
|
716
|
+
case value
|
|
717
|
+
when Hash then documents.concat(value.values)
|
|
718
|
+
when Array then documents.concat(value)
|
|
719
|
+
when String
|
|
720
|
+
match = value.match(%r{\A(?:#{Regexp.escape(BACKUP_ROOT)}/|backup/)(\d{8}_\d{6}(?:_[a-f0-9]{8})?)(?:/|\z)})
|
|
721
|
+
pins << match[1] if match
|
|
722
|
+
end
|
|
723
|
+
end
|
|
724
|
+
end
|
|
725
|
+
end
|
|
726
|
+
pins.uniq
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
private_class_method def self.expire_backup_generations(opts = {})
|
|
730
|
+
require 'zlib'
|
|
731
|
+
result = { eligible: [], deleted: [], retained: [], incomplete: false, permanent_history_expiry: true }
|
|
732
|
+
return result unless File.directory?(BACKUP_ROOT)
|
|
733
|
+
raise IOError, 'Unsafe backup root' unless File.realpath(PWN_ROOT) == PWN_ROOT && File.realpath(BACKUP_ROOT) == BACKUP_ROOT && File.stat(BACKUP_ROOT).uid == Process.uid
|
|
734
|
+
|
|
735
|
+
dry_run = opts.fetch(:dry_run, true)
|
|
736
|
+
admission = opts[:admission]
|
|
737
|
+
policy = backup_retention_policy
|
|
738
|
+
keep = [Integer(opts.fetch(:keep, policy['keep'])), 3].max
|
|
739
|
+
limit = Integer(opts.fetch(:limit, 10))
|
|
740
|
+
raise ArgumentError, 'Invalid retention limit' unless limit.positive?
|
|
741
|
+
|
|
742
|
+
operation = lambda do
|
|
743
|
+
names = Dir.children(BACKUP_ROOT).grep(/\A\d{8}_\d{6}(?:_[a-f0-9]{8})?\z/).sort
|
|
744
|
+
raise IOError, 'Pinned backup missing' unless (policy['pins'] - names).empty?
|
|
745
|
+
|
|
746
|
+
if (names - (names.last(keep) + policy['pins'])).empty?
|
|
747
|
+
result[:retained] = names
|
|
748
|
+
result[:pins] = policy['pins']
|
|
749
|
+
result[:evidence_scan] = 'not_needed_no_expiry'
|
|
750
|
+
return result
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
pins = (policy['pins'] + backup_reference_pins(admission: admission)).uniq
|
|
754
|
+
raise IOError, 'Pinned backup missing' unless (pins - names).empty?
|
|
755
|
+
|
|
756
|
+
retained = (names.last(keep) + pins).uniq.sort
|
|
757
|
+
result[:retained] = retained
|
|
758
|
+
result[:pins] = pins
|
|
759
|
+
return result if (names - retained).empty?
|
|
760
|
+
raise IOError, 'Insufficient recovery generations' if retained.length < keep
|
|
761
|
+
|
|
762
|
+
current = backup_inventory(path: PWN_ROOT, names: backup_input_names, admission: admission)
|
|
763
|
+
inventories = retained.to_h { |name| [name, backup_inventory(path: File.join(BACKUP_ROOT, name), admission: admission)] }
|
|
764
|
+
newest = inventories.fetch(names.last)
|
|
765
|
+
raise IOError, 'Current migration input coverage missing' unless current.all? { |path, row| newest[path] && newest[path].first == row.first }
|
|
766
|
+
|
|
767
|
+
%w[pwn.yaml pwn.yaml.decryptor].each do |name|
|
|
768
|
+
raise IOError, 'Current encrypted configuration recovery missing' if current.key?(name) && newest[name] != current[name]
|
|
769
|
+
end
|
|
770
|
+
result[:coverage] = { current_paths: current.length, newest: names.last, legacy_completeness_certified: false, restore: 'copy migration-owned paths only; never overlay entire legacy trees or edit shared inodes' }
|
|
771
|
+
candidates = (names - retained).first(limit)
|
|
772
|
+
expired = candidates.to_h { |name| [name, backup_inventory(path: File.join(BACKUP_ROOT, name), admission: admission)] }
|
|
773
|
+
result[:eligible] = candidates
|
|
774
|
+
return result if dry_run
|
|
775
|
+
|
|
776
|
+
# Recheck everything before publishing the single private metadata ledger.
|
|
777
|
+
raise IOError, 'Current migration input changed' unless current == backup_inventory(path: PWN_ROOT, names: backup_input_names, admission: admission)
|
|
778
|
+
raise IOError, 'Backup references changed' unless pins == (policy['pins'] + backup_reference_pins(admission: admission)).uniq
|
|
779
|
+
|
|
780
|
+
inventories.merge(expired).each do |name, rows|
|
|
781
|
+
raise IOError, 'Backup changed before expiry' unless rows == backup_inventory(path: File.join(BACKUP_ROOT, name), admission: admission)
|
|
782
|
+
end
|
|
783
|
+
ledger = File.join(BACKUP_ROOT, '.retention-manifest.json.gz')
|
|
784
|
+
raise IOError, 'Unsafe retention manifest' if File.symlink?(ledger)
|
|
785
|
+
|
|
786
|
+
record = { version: 1, state: 'prepared', policy: policy, coverage: result[:coverage], retained: inventories, expiring: expired, deleted: [] }
|
|
787
|
+
verification_bytes = inventories.values.sum { |rows| rows.values.sum { |row| row.first == 'file' ? row[4] + 1 : 0 } }
|
|
788
|
+
reserved_bytes = (JSON.generate(record).bytesize * 2) + verification_bytes
|
|
789
|
+
raise IOError, 'Backup commit budget exhausted' if admission && !admission.call(reserved_bytes)
|
|
790
|
+
|
|
791
|
+
publish_backup_retention(path: ledger, record: record)
|
|
792
|
+
candidates.each do |name|
|
|
793
|
+
FileUtils.remove_entry_secure(File.join(BACKUP_ROOT, name))
|
|
794
|
+
raise IOError, 'Backup expiry failed' if File.exist?(File.join(BACKUP_ROOT, name))
|
|
795
|
+
|
|
796
|
+
result[:deleted] << name
|
|
797
|
+
record[:deleted] << name
|
|
798
|
+
end
|
|
799
|
+
inventories.each do |name, rows|
|
|
800
|
+
raise IOError, 'Retained backup changed' unless rows == backup_inventory(path: File.join(BACKUP_ROOT, name))
|
|
801
|
+
end
|
|
802
|
+
record[:state] = 'committed'
|
|
803
|
+
publish_backup_retention(path: ledger, record: record)
|
|
804
|
+
result
|
|
805
|
+
end
|
|
806
|
+
return operation.call if dry_run
|
|
807
|
+
|
|
808
|
+
File.open(File.join(BACKUP_ROOT, '.retention.lock'), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock|
|
|
809
|
+
raise IOError, 'Backup maintenance active' unless lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
810
|
+
|
|
811
|
+
operation.call
|
|
812
|
+
end
|
|
813
|
+
rescue IOError => e
|
|
814
|
+
result[:incomplete] = true
|
|
815
|
+
result[:error] = e.message
|
|
816
|
+
result
|
|
817
|
+
end
|
|
818
|
+
|
|
819
|
+
private_class_method def self.publish_backup_retention(opts = {})
|
|
820
|
+
path = opts[:path]
|
|
821
|
+
temporary = "#{path}.#{SecureRandom.hex(8)}"
|
|
822
|
+
File.open(temporary, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
|
|
823
|
+
writer = Zlib::GzipWriter.new(file)
|
|
824
|
+
writer.write(JSON.generate(opts[:record]))
|
|
825
|
+
writer.finish
|
|
826
|
+
file.flush
|
|
827
|
+
file.fsync
|
|
828
|
+
end
|
|
829
|
+
File.rename(temporary, path)
|
|
830
|
+
File.open(BACKUP_ROOT, File::RDONLY, &:fsync)
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
private_class_method def self.backup_inventory(opts = {})
|
|
834
|
+
path = opts[:path]
|
|
835
|
+
admission = opts[:admission]
|
|
836
|
+
names = opts[:names]
|
|
837
|
+
raise IOError, 'Unsafe backup directory' unless File.realpath(path) == path && File.directory?(path)
|
|
838
|
+
|
|
839
|
+
rows = {}
|
|
840
|
+
Find.find(path) do |file|
|
|
841
|
+
next if file == path
|
|
842
|
+
|
|
843
|
+
rel = file.delete_prefix("#{path}/")
|
|
844
|
+
Find.prune if names && names.none? { |name| rel == name || rel.start_with?("#{name}/") || name.start_with?("#{rel}/") }
|
|
845
|
+
stat = File.lstat(file)
|
|
846
|
+
raise IOError, 'Unsafe backup entry' unless (stat.file? || stat.directory?) && stat.uid == Process.uid
|
|
847
|
+
next if names && names.none? { |name| rel == name || rel.start_with?("#{name}/") }
|
|
848
|
+
raise IOError, 'Backup work budget exhausted' if admission && !admission.call(stat.file? ? stat.size + 1 : 0)
|
|
849
|
+
|
|
850
|
+
row = [stat.ftype, stat.mode, stat.uid, stat.gid]
|
|
851
|
+
if stat.file?
|
|
852
|
+
row += [stat.size, Digest::SHA256.file(file).hexdigest]
|
|
853
|
+
after = File.lstat(file)
|
|
854
|
+
raise IOError, 'Backup changed while hashing' unless [stat.ino, stat.size, stat.mtime, stat.ctime] == [after.ino, after.size, after.mtime, after.ctime]
|
|
855
|
+
end
|
|
856
|
+
rows[rel] = row
|
|
857
|
+
end
|
|
858
|
+
rows
|
|
859
|
+
end
|
|
860
|
+
|
|
861
|
+
private_class_method def self.backup!
|
|
862
|
+
FileUtils.mkdir_p(BACKUP_ROOT, mode: 0o700)
|
|
863
|
+
raise IOError, 'Unsafe backup root' unless File.realpath(BACKUP_ROOT) == BACKUP_ROOT
|
|
864
|
+
|
|
865
|
+
File.open(File.join(BACKUP_ROOT, '.retention.lock'), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock|
|
|
866
|
+
lock.flock(File::LOCK_EX)
|
|
867
|
+
ts = "#{Time.now.utc.strftime('%Y%m%d_%H%M%S')}_#{SecureRandom.hex(4)}"
|
|
868
|
+
dst = File.join(BACKUP_ROOT, ts)
|
|
869
|
+
partial = File.join(BACKUP_ROOT, ".partial-#{ts}")
|
|
870
|
+
Dir.mkdir(partial, 0o700)
|
|
871
|
+
# Only state owned by the migration registry. Logs, caches, artifacts,
|
|
872
|
+
# jobs and prior maintenance archives are not schema migration inputs.
|
|
873
|
+
names = backup_input_names
|
|
874
|
+
inventory = backup_inventory(path: PWN_ROOT, names: names)
|
|
875
|
+
names.each do |name|
|
|
876
|
+
src = File.join(PWN_ROOT, name)
|
|
877
|
+
next unless File.exist?(src)
|
|
878
|
+
|
|
879
|
+
target = File.join(partial, name)
|
|
880
|
+
FileUtils.mkdir_p(File.dirname(target), mode: 0o700)
|
|
881
|
+
FileUtils.cp_r(src, target, preserve: true)
|
|
882
|
+
end
|
|
883
|
+
raise IOError, 'Migration input changed during backup' unless inventory == backup_inventory(path: PWN_ROOT, names: names)
|
|
884
|
+
raise IOError, 'Backup verification failed' unless inventory == backup_inventory(path: partial, names: names)
|
|
885
|
+
|
|
886
|
+
manifest = JSON.generate(version: 1, complete: true, scope: names.sort, files: inventory)
|
|
887
|
+
File.open(File.join(partial, '.snapshot-manifest.json'), File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
|
|
888
|
+
file.write(manifest)
|
|
889
|
+
file.flush
|
|
890
|
+
file.fsync
|
|
891
|
+
end
|
|
892
|
+
File.rename(partial, dst)
|
|
893
|
+
File.open(BACKUP_ROOT, File::RDONLY, &:fsync)
|
|
894
|
+
dst
|
|
626
895
|
end
|
|
627
|
-
dst
|
|
628
896
|
end
|
|
629
897
|
|
|
630
898
|
private_class_method def self.stamp!
|
|
@@ -773,6 +1041,15 @@ module PWN
|
|
|
773
1041
|
iv: 'optional - iv value consumed by #backfill_vault'
|
|
774
1042
|
)
|
|
775
1043
|
|
|
1044
|
+
# Prune duplicate trees, or explicitly expire historical generations.
|
|
1045
|
+
#{self}.prune_backups(
|
|
1046
|
+
expire_history: 'optional - permanently expire old unpinned history after current recovery coverage verification; default reads backup/.retention-policy.json',
|
|
1047
|
+
dry_run: 'optional - preview without writes, default true',
|
|
1048
|
+
keep: 'optional - recent recovery points to retain, minimum three',
|
|
1049
|
+
limit: 'optional - maximum older snapshots examined per invocation, default ten',
|
|
1050
|
+
admission: 'optional - callable byte/work budget admission guard'
|
|
1051
|
+
)
|
|
1052
|
+
|
|
776
1053
|
# Run env template and return its result
|
|
777
1054
|
#{self}.env_template
|
|
778
1055
|
|
|
@@ -101,16 +101,17 @@ module PWN
|
|
|
101
101
|
sha = Digest::SHA256.file(path).hexdigest
|
|
102
102
|
sid = (opts[:session_id] || 'triage').to_s
|
|
103
103
|
cache_dir = File.join(Dir.home, '.pwn', 'cache', 'triage')
|
|
104
|
-
FileUtils.mkdir_p(cache_dir)
|
|
105
104
|
cache = File.join(cache_dir, "#{sha}.json")
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
require File.expand_path('../../../etc/default_cron/session_cleanup', __dir__)
|
|
106
|
+
cached_bytes = PWN::SessionCleanup.cache(path: cache)
|
|
107
|
+
if cached_bytes
|
|
108
|
+
cached = JSON.parse(cached_bytes, symbolize_names: true)
|
|
108
109
|
spilled = persist_triage(body: cached, session_id: sid)
|
|
109
110
|
return cached.merge(cached: true, handle: spilled[:handle], artifact: spilled)
|
|
110
111
|
end
|
|
111
112
|
|
|
112
113
|
body = inspect_binary(opts.merge(path: path, sha256: sha))
|
|
113
|
-
|
|
114
|
+
PWN::SessionCleanup.cache(path: cache, bytes: JSON.pretty_generate(body))
|
|
114
115
|
facts_dir = File.join(Dir.home, '.pwn', 'engagements', 'default', 'binaries', sha)
|
|
115
116
|
FileUtils.mkdir_p(facts_dir)
|
|
116
117
|
File.write(File.join(facts_dir, 'facts.json'), JSON.pretty_generate(body))
|
data/lib/pwn/plugins/ghidra.rb
CHANGED
|
@@ -37,13 +37,14 @@ module PWN
|
|
|
37
37
|
|
|
38
38
|
sha = Digest::SHA256.file(path).hexdigest if File.file?(path)
|
|
39
39
|
cache = File.join(Dir.home, '.pwn', 'cache', 'decompile', "#{sha}.json") if sha
|
|
40
|
-
|
|
40
|
+
require File.expand_path('../../../etc/default_cron/session_cleanup', __dir__)
|
|
41
|
+
cached = PWN::SessionCleanup.cache(path: cache) if cache
|
|
42
|
+
return JSON.parse(cached, symbolize_names: true).merge(cached: true) if cached
|
|
41
43
|
|
|
42
44
|
row = analyze(opts)
|
|
43
45
|
fn = opts[:function].to_s
|
|
44
46
|
row[:function] = fn unless fn.empty?
|
|
45
|
-
|
|
46
|
-
File.write(cache, JSON.generate(row)) if cache
|
|
47
|
+
PWN::SessionCleanup.cache(path: cache, bytes: JSON.generate(row)) if cache
|
|
47
48
|
row
|
|
48
49
|
end
|
|
49
50
|
|
data/lib/pwn/plugins/log.rb
CHANGED
|
@@ -60,8 +60,9 @@ module PWN
|
|
|
60
60
|
log_file_path = "/tmp/pwn-ai-#{session}.json" if level == :learning
|
|
61
61
|
log_file = File.open(log_file_path, 'w')
|
|
62
62
|
else
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
require 'stringio'
|
|
64
|
+
log_file_path = File.join(Dir.home, '.pwn', 'logs', 'pwn.log')
|
|
65
|
+
log_file = StringIO.new
|
|
65
66
|
end
|
|
66
67
|
|
|
67
68
|
# Leave 10 "old" log files where
|
|
@@ -124,6 +125,10 @@ module PWN
|
|
|
124
125
|
end
|
|
125
126
|
|
|
126
127
|
logger.add(logger.level, PWN::Redaction.redact(value: log_event), PWN::Redaction.redact(value: which_self))
|
|
128
|
+
unless level == :learning
|
|
129
|
+
require File.expand_path('../../../etc/default_cron/session_cleanup', __dir__)
|
|
130
|
+
PWN::SessionCleanup.append_log(path: log_file_path, bytes: log_file.string)
|
|
131
|
+
end
|
|
127
132
|
logger.close
|
|
128
133
|
rescue Interrupt
|
|
129
134
|
note_interrupt!(where: 'CTRL+C', which_self: self) if debug_enabled?
|
|
@@ -424,9 +429,18 @@ module PWN
|
|
|
424
429
|
unless @debug_file.nil? || @debug_file.closed?
|
|
425
430
|
@debug_file.puts(PWN::Redaction.token(kind: 'stream', value: @stderr_redaction_buffer)) unless @stderr_redaction_buffer.to_s.empty?
|
|
426
431
|
@debug_file.close
|
|
432
|
+
@debug_lease&.close
|
|
433
|
+
@debug_lease = nil
|
|
434
|
+
if @debug_path.to_s.start_with?("#{File.join(Dir.home, '.pwn', 'logs')}/")
|
|
435
|
+
require File.expand_path('../../../etc/default_cron/session_cleanup', __dir__)
|
|
436
|
+
PWN::SessionCleanup.register_disposable(path: @debug_path, category: 'logs')
|
|
437
|
+
end
|
|
427
438
|
end
|
|
428
439
|
rescue StandardError
|
|
429
440
|
nil
|
|
441
|
+
ensure
|
|
442
|
+
@debug_lease&.close
|
|
443
|
+
@debug_lease = nil
|
|
430
444
|
end
|
|
431
445
|
@debug_file = nil
|
|
432
446
|
@stderr_redaction_buffer = ''
|
|
@@ -439,6 +453,8 @@ module PWN
|
|
|
439
453
|
|
|
440
454
|
close_debug_file!
|
|
441
455
|
FileUtils.mkdir_p(File.dirname(path))
|
|
456
|
+
@debug_lease = File.open("#{path}.lease", File::RDWR | File::CREAT | File::NOFOLLOW, 0o600)
|
|
457
|
+
@debug_lease.flock(File::LOCK_EX)
|
|
442
458
|
io = File.open(path, 'a', 0o600)
|
|
443
459
|
io.chmod(0o600)
|
|
444
460
|
io.sync = true
|
data/lib/pwn/plugins/repl/ai.rb
CHANGED
|
@@ -129,7 +129,9 @@ module PWN
|
|
|
129
129
|
case cmd
|
|
130
130
|
when 'list', nil
|
|
131
131
|
output.puts('Session dependencies: referenced by these files; text mentions are not links.')
|
|
132
|
-
PWN::Plugins::REPL.pwn_ai_print_result(value: PWN::Sessions.
|
|
132
|
+
PWN::Plugins::REPL.pwn_ai_print_result(value: PWN::Sessions.catalog(offset: args[1], limit: args[2] || 25, current_session_id: pry_instance.config.pwn_ai_session_id), output: output)
|
|
133
|
+
when 'cleanup'
|
|
134
|
+
PWN::Plugins::REPL.pwn_ai_run_sessions(args: args)
|
|
133
135
|
when 'resume'
|
|
134
136
|
sid = args[1]
|
|
135
137
|
hist = PWN::Sessions.to_response_history(session_id: sid)
|
|
@@ -248,7 +250,7 @@ module PWN
|
|
|
248
250
|
end
|
|
249
251
|
end
|
|
250
252
|
PWN_AI_SLASH_COMMANDS = %w[
|
|
251
|
-
/back /cron /debug /delegate /help /learning /memory /mcp /model /sessions /skills /steer /swarm /system-role /trace /vault
|
|
253
|
+
/back /cron /debug /delegate /help /learning /maintenance /memory /mcp /model /sessions /skills /steer /swarm /system-role /trace /vault
|
|
252
254
|
].freeze
|
|
253
255
|
|
|
254
256
|
PWN_AI_SLASH_SUBCOMMANDS = {
|
|
@@ -262,7 +264,8 @@ module PWN
|
|
|
262
264
|
'/mcp' => %w[list backends use current connect disconnect ping tools call status help],
|
|
263
265
|
'/model' => %w[list],
|
|
264
266
|
'/swarm' => %w[mission agents status create use spawn retire dm debate broadcast tail steer pause resume cancel help],
|
|
265
|
-
'/sessions' => %w[list resume delete stats],
|
|
267
|
+
'/sessions' => %w[list resume delete stats cleanup],
|
|
268
|
+
'/maintenance' => %w[status run dry-run],
|
|
266
269
|
|
|
267
270
|
'/skills' => %w[list recall],
|
|
268
271
|
'/learning' => %w[list requeue]
|
|
@@ -578,6 +581,16 @@ module PWN
|
|
|
578
581
|
end
|
|
579
582
|
when '/cron'
|
|
580
583
|
pwn_ai_run_cron(args: args)
|
|
584
|
+
when '/maintenance'
|
|
585
|
+
require File.expand_path('../../../../etc/default_cron/session_cleanup', __dir__)
|
|
586
|
+
raise ArgumentError, 'usage: /maintenance [status|run|dry-run]' unless args.empty? || (args.length == 1 && %w[status run dry-run].include?(args.first))
|
|
587
|
+
|
|
588
|
+
result = if args.empty? || args.first == 'status'
|
|
589
|
+
PWN::SessionCleanup.status
|
|
590
|
+
else
|
|
591
|
+
PWN::SessionCleanup.maintain(dry_run: args.first == 'dry-run', current_session_id: pi&.config&.pwn_ai_session_id)
|
|
592
|
+
end
|
|
593
|
+
pwn_ai_print_result(value: result)
|
|
581
594
|
when '/sessions'
|
|
582
595
|
identifier = request.sub(%r{\A/sessions[[:space:]]+resume(?:[[:space:]]+|$)}, '') if args.first == 'resume'
|
|
583
596
|
pwn_ai_run_sessions(args: args, identifier: identifier, pry: pi)
|
|
@@ -599,6 +612,8 @@ module PWN
|
|
|
599
612
|
end
|
|
600
613
|
true
|
|
601
614
|
rescue StandardError => e
|
|
615
|
+
raise if opts[:raise_errors]
|
|
616
|
+
|
|
602
617
|
warn "[pwn-ai] #{cmd}: #{e.class}: #{e.message}"
|
|
603
618
|
true
|
|
604
619
|
end
|
|
@@ -1038,7 +1053,13 @@ module PWN
|
|
|
1038
1053
|
case sub
|
|
1039
1054
|
when 'list'
|
|
1040
1055
|
puts('Session dependencies: referenced by these files; text mentions are not links.')
|
|
1041
|
-
pwn_ai_print_result(value: PWN::Sessions.
|
|
1056
|
+
pwn_ai_print_result(value: PWN::Sessions.catalog(offset: args[1], limit: args[2] || 25, current_session_id: opts[:pry]&.config&.pwn_ai_session_id))
|
|
1057
|
+
when 'cleanup'
|
|
1058
|
+
raise ArgumentError, 'usage: /sessions cleanup [--dry-run]' unless args.drop(1).empty? || args.drop(1) == ['--dry-run']
|
|
1059
|
+
|
|
1060
|
+
require File.expand_path('../../../../etc/default_cron/session_cleanup', __dir__)
|
|
1061
|
+
result = PWN::SessionCleanup.maintain(dry_run: args.include?('--dry-run'), current_session_id: opts[:pry]&.config&.pwn_ai_session_id)
|
|
1062
|
+
pwn_ai_print_result(value: PWN::SessionCleanup.summary(result))
|
|
1042
1063
|
when 'resume'
|
|
1043
1064
|
sid = PWN::Sessions.resolve(identifier: opts[:identifier] || args.drop(1).join(' '))
|
|
1044
1065
|
lease = PWN::Sessions.activate(session_id: sid)
|