pwn 0.5.783 → 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 +247 -6
- 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/etc/default_cron/session_cleanup.rb +1217 -10
- data/lib/pwn/ai/agent/extrospection.rb +6 -7
- data/lib/pwn/ai/agent/learning.rb +48 -7
- data/lib/pwn/ai/agent/policy.rb +5 -0
- data/lib/pwn/ai/agent/swarm.rb +31 -16
- data/lib/pwn/ai/router.rb +45 -2
- data/lib/pwn/cron.rb +120 -49
- 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 +12 -1
- data/lib/pwn/plugins/repl/ai_console.rb +6 -2
- data/lib/pwn/plugins/repl/ai_swarm.rb +4 -4
- data/lib/pwn/sessions.rb +58 -16
- 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/policy_spec.rb +27 -0
- data/spec/lib/pwn/ai/router_spec.rb +30 -2
- data/spec/lib/pwn/cron_spec.rb +113 -6
- 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 +16 -4
- 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 +165 -2
- data/spec/lib/pwn/session_cleanup_legacy_spec.rb +101 -0
- data/spec/lib/pwn/session_cleanup_payload_spec.rb +335 -2
- data/spec/lib/pwn/session_cleanup_recovery_spec.rb +389 -0
- data/spec/lib/pwn/sessions_spec.rb +112 -0
- data/spec/lib/pwn/setup_spec.rb +3 -1
- data/spec/support/gate_failure_formatter.rb +14 -0
- data/third_party/pwn_rdoc.jsonl +15 -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
|
@@ -250,7 +250,7 @@ module PWN
|
|
|
250
250
|
end
|
|
251
251
|
end
|
|
252
252
|
PWN_AI_SLASH_COMMANDS = %w[
|
|
253
|
-
/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
|
|
254
254
|
].freeze
|
|
255
255
|
|
|
256
256
|
PWN_AI_SLASH_SUBCOMMANDS = {
|
|
@@ -265,6 +265,7 @@ module PWN
|
|
|
265
265
|
'/model' => %w[list],
|
|
266
266
|
'/swarm' => %w[mission agents status create use spawn retire dm debate broadcast tail steer pause resume cancel help],
|
|
267
267
|
'/sessions' => %w[list resume delete stats cleanup],
|
|
268
|
+
'/maintenance' => %w[status run dry-run],
|
|
268
269
|
|
|
269
270
|
'/skills' => %w[list recall],
|
|
270
271
|
'/learning' => %w[list requeue]
|
|
@@ -580,6 +581,16 @@ module PWN
|
|
|
580
581
|
end
|
|
581
582
|
when '/cron'
|
|
582
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)
|
|
583
594
|
when '/sessions'
|
|
584
595
|
identifier = request.sub(%r{\A/sessions[[:space:]]+resume(?:[[:space:]]+|$)}, '') if args.first == 'resume'
|
|
585
596
|
pwn_ai_run_sessions(args: args, identifier: identifier, pry: pi)
|
|
@@ -277,6 +277,10 @@ module PWN
|
|
|
277
277
|
class Control < PWN::AI::Agent::Loop::Steering
|
|
278
278
|
class Stopped < Exception; end # rubocop:disable Lint/InheritException
|
|
279
279
|
|
|
280
|
+
def stopping?
|
|
281
|
+
@stopping == true
|
|
282
|
+
end
|
|
283
|
+
|
|
280
284
|
def stop
|
|
281
285
|
@stopping = true
|
|
282
286
|
request_model_cancel
|
|
@@ -741,7 +745,7 @@ module PWN
|
|
|
741
745
|
end
|
|
742
746
|
# Maintenance is one safe transaction: never label its committed
|
|
743
747
|
# deletions cancelled merely because stop was requested mid-write.
|
|
744
|
-
if request.match?(%r{\A/sessions\s+cleanup(?:\s|$)})
|
|
748
|
+
if request.match?(%r{\A/(?:sessions\s+cleanup|maintenance)(?:\s|$)})
|
|
745
749
|
@events << [:notice, 'Session cleanup finished; completed work is not undone.']
|
|
746
750
|
else
|
|
747
751
|
control.checkpoint(messages: [])
|
|
@@ -848,7 +852,7 @@ module PWN
|
|
|
848
852
|
open_vault_edit(line)
|
|
849
853
|
elsif line.strip == '/system-role'
|
|
850
854
|
open_system_role
|
|
851
|
-
elsif line.match?(%r{\A/(?:mcp(?:\s|$)|cron\s+run(?:\s|$)|model(?:\s|$)|sessions\s+cleanup(?:\s|$))})
|
|
855
|
+
elsif line.match?(%r{\A/(?:maintenance(?:\s|$)|mcp(?:\s|$)|cron\s+run(?:\s|$)|model(?:\s|$)|sessions\s+cleanup(?:\s|$))})
|
|
852
856
|
start_request(line, command: true)
|
|
853
857
|
elsif line.start_with?('/') || line.start_with?('ai.profile ', 'ai.memory ')
|
|
854
858
|
if line.match?(%r{\A/sessions[[:space:]]+resume(?:[[:space:]]|$)})
|
|
@@ -164,7 +164,7 @@ module PWN
|
|
|
164
164
|
when 'create' then create(tokens.join(' '))
|
|
165
165
|
when 'use' then use(tokens.first)
|
|
166
166
|
when 'spawn' then spawn(tokens)
|
|
167
|
-
when 'retire' then retire(tokens.first)
|
|
167
|
+
when 'retire' then retire(tokens.first, continuation: tokens.include?('--continuation'))
|
|
168
168
|
when 'dm' then ask(tokens, opts)
|
|
169
169
|
when 'mission' then solve(tokens, opts)
|
|
170
170
|
when 'debate' then debate(tokens, opts)
|
|
@@ -180,7 +180,7 @@ module PWN
|
|
|
180
180
|
def help_text
|
|
181
181
|
{
|
|
182
182
|
ok: true,
|
|
183
|
-
usage: '/swarm mission REQUEST|agents|status [JOB]|create [topic]|use ID|spawn NAME ROLE [--engine E --model M --toolsets a,b]|retire NAME|dm NAME REQUEST|debate NAMES TOPIC [--rounds N]|broadcast REQUEST [--names a,b]|tail [N]|steer JOB INSTRUCTION|pause JOB|resume JOB|cancel JOB|all'
|
|
183
|
+
usage: '/swarm mission REQUEST|agents|status [JOB]|create [topic]|use ID|spawn NAME ROLE [--engine E --model M --toolsets a,b]|retire NAME [--continuation]|dm NAME REQUEST|debate NAMES TOPIC [--rounds N]|broadcast REQUEST [--names a,b]|tail [N]|steer JOB INSTRUCTION|pause JOB|resume JOB|cancel JOB|all'
|
|
184
184
|
}
|
|
185
185
|
end
|
|
186
186
|
|
|
@@ -215,10 +215,10 @@ module PWN
|
|
|
215
215
|
{ ok: true, persona: result[:name], swarm_id: @swarm_id, scope: :swarm }
|
|
216
216
|
end
|
|
217
217
|
|
|
218
|
-
def retire(name)
|
|
218
|
+
def retire(name, continuation: false)
|
|
219
219
|
return { ok: false, error: 'invalid persona name' } unless identifier?(name)
|
|
220
220
|
|
|
221
|
-
result = PWN::AI::Agent::Swarm.retire(name: name, swarm_id: @swarm_id, local: true)
|
|
221
|
+
result = PWN::AI::Agent::Swarm.retire(name: name, swarm_id: @swarm_id, local: true, continuation: continuation)
|
|
222
222
|
{ ok: result[:removed], scope: result[:scope], name: name }
|
|
223
223
|
end
|
|
224
224
|
|