pwn 0.5.635 → 0.5.636
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/Rakefile +6 -0
- data/bin/pwn_setup +16 -0
- data/documentation/Home.md +7 -6
- data/documentation/Installation.md +26 -1
- data/lib/pwn/config.rb +42 -20
- data/lib/pwn/migrate.rb +716 -0
- data/lib/pwn/setup.rb +34 -2
- data/lib/pwn/version.rb +1 -1
- data/lib/pwn.rb +1 -0
- data/spec/documentation/installation_md_spec.rb +2 -2
- data/spec/lib/pwn/ai/agent/mistakes_spec.rb +2 -0
- data/spec/lib/pwn/migrate_spec.rb +136 -0
- data/spec/spec_helper.rb +35 -0
- data/spec/support/sandbox.rb +6 -0
- data/third_party/pwn_rdoc.jsonl +24 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 41a8a5c5d750508d4f3904d7a05151719e2c86fa4cf20001a3aab0985910963d
|
|
4
|
+
data.tar.gz: 7af0757b53bdfa6c97d86a9415a2cbf41ca9447b3f9e438b95b2424f5d835cbf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8053f31ef3138368e50c1b6920fa7804945f508daabc31191ba2a9578f3c31f7e6629e1ca901a409ec82e4cefdf3a824fa48bcfe4fdcd8ab4a1a9da0f168e1da
|
|
7
|
+
data.tar.gz: 592bfb24c2c2a610813fcd5b8431478d4774ef312405f446393db9cbb305c2c223b129a0714b9b464adc9976e682b1754a4e5d760d0ca28b00ba99778df21d7a
|
data/Rakefile
CHANGED
|
@@ -16,7 +16,13 @@ if defined?(RDoc::Task)
|
|
|
16
16
|
RDoc::Task.new do |rdoc|
|
|
17
17
|
rdoc.rdoc_files.include('lib/**/*.rb')
|
|
18
18
|
rdoc.rdoc_dir = 'rdoc'
|
|
19
|
+
rdoc.options << '--quiet'
|
|
19
20
|
end
|
|
21
|
+
# RDoc::Task's default :clobber_rdoc uses Rake's verbose FileUtils, which
|
|
22
|
+
# echoes "rm -r rdoc" to STDOUT on every `rake` (via :rerdoc). Replace it
|
|
23
|
+
# with a silent rm_rf so the default task emits only spec/rubocop output.
|
|
24
|
+
Rake::Task[:clobber_rdoc].clear_actions
|
|
25
|
+
task(:clobber_rdoc) { FileUtils.rm_rf('rdoc') }
|
|
20
26
|
end
|
|
21
27
|
|
|
22
28
|
default_tasks = %i[spec rubocop]
|
data/bin/pwn_setup
CHANGED
|
@@ -29,6 +29,16 @@ pwn_driver = PWN::Driver::Parser.new do |options|
|
|
|
29
29
|
opts[:list] = o
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
options.on('-m', '--migrate',
|
|
33
|
+
'<Optional - Verify ~/.pwn state files are compatible with this pwn version; apply schema migrations>') do |o|
|
|
34
|
+
opts[:migrate] = o
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
options.on('-f', '--fix',
|
|
38
|
+
'<Optional - With --migrate: also autofix incompatible files (backup taken first)>') do |o|
|
|
39
|
+
opts[:fix] = o
|
|
40
|
+
end
|
|
41
|
+
|
|
32
42
|
options.on('-y', '--yes',
|
|
33
43
|
'<Optional - Assume yes; non-interactive (CI-friendly)>') do |o|
|
|
34
44
|
opts[:yes] = o
|
|
@@ -45,6 +55,12 @@ pwn_driver.parse!
|
|
|
45
55
|
begin
|
|
46
56
|
if opts[:list]
|
|
47
57
|
PWN::Setup.list_profiles
|
|
58
|
+
elsif opts[:migrate]
|
|
59
|
+
r = PWN::Setup.migrate(
|
|
60
|
+
fix: opts[:fix] || opts[:yes],
|
|
61
|
+
dry_run: opts[:dry_run]
|
|
62
|
+
)
|
|
63
|
+
exit 1 unless r && r[:after] && r[:after][:incompatible].empty?
|
|
48
64
|
elsif opts[:deps] || opts[:profile]
|
|
49
65
|
PWN::Setup.deps(
|
|
50
66
|
profile: opts[:profile],
|
data/documentation/Home.md
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
| [What is PWN](What-is-PWN.md) | One-paragraph elevator pitch |
|
|
18
18
|
| [Why PWN](Why-PWN.md) | Design philosophy - why another framework |
|
|
19
19
|
| [How PWN Works](How-PWN-Works.md) | The five layers, with the architecture diagram |
|
|
20
|
-
| [Installation](Installation.md) | `gem install pwn` → `pwn setup` doctor/provisioner · capability profiles |
|
|
20
|
+
| [Installation](Installation.md) | `gem install pwn` → `pwn setup` doctor/provisioner · capability profiles · `--migrate` state doctor |
|
|
21
21
|
| [General Usage](General-PWN-Usage.md) | Day-one cheat sheet |
|
|
22
22
|
| [Configuration](Configuration.md) | `~/.pwn/pwn.yaml` (encrypted) - engines, keys, agent options · `pwn-vault` |
|
|
23
23
|
| **[All Data-Flow Diagrams](Diagrams.md)** | **27 SVGs** in one scrollable page |
|
|
@@ -36,13 +36,14 @@
|
|
|
36
36
|
| | |
|
|
37
37
|
|---|---|
|
|
38
38
|
| [AI / LLM Integration](AI-Integration.md) | OpenAI · Anthropic · Grok (OAuth) · Gemini · Ollama |
|
|
39
|
-
| [Agent Tool Registry](Agent-Tool-Registry.md) | 10 toolsets ·
|
|
39
|
+
| [Agent Tool Registry](Agent-Tool-Registry.md) | 10 toolsets · **71** LLM-callable tools |
|
|
40
40
|
| [Memory · Skills · Learning](Skills-Memory-Learning.md) | Introspection - the self-improvement loop |
|
|
41
41
|
| [Mistakes](Mistakes.md) | **Negative feedback** - fingerprint failures · do-NOT-repeat · `[REPEATING]`/`[REGRESSED]` · inline self-correction |
|
|
42
|
-
| [
|
|
42
|
+
| [Reinforcement Learning](Reinforcement-Learning.md) | **`Reward` + `Curriculum`** - ORM/PRM judge · sentinel · HER · self-play · DPO export · **regression-gated LoRA** |
|
|
43
|
+
| [Extrospection](Extrospection.md) | World-awareness - snapshot · drift · intel · **watch** · **verify** · **rf_tune** · **osint** · serial · telecomm · packet · vision · voice · correlate |
|
|
43
44
|
| [Swarm (Multi-Agent)](Swarm.md) | Personas · ask · debate · broadcast · shared bus |
|
|
44
45
|
| [Sessions](Sessions.md) | Transcript persistence + reflection |
|
|
45
|
-
| [Cron](Cron.md) | Scheduled autonomous jobs |
|
|
46
|
+
| [Cron](Cron.md) | Scheduled autonomous jobs (nightly self-play + weekly weight-loop seeded by default) |
|
|
46
47
|
|
|
47
48
|
## 🧩 Capability Namespaces (`lib/pwn/*`)
|
|
48
49
|
|
|
@@ -62,14 +63,14 @@
|
|
|
62
63
|
| [Blockchain](Blockchain.md) | BTC · ETH helpers |
|
|
63
64
|
| [Bounty](Bounty.md) | Lifecycle / auth-replay tooling |
|
|
64
65
|
| [Reports](Reporting.md) | HTML/JSON output + DefectDojo/Jira |
|
|
65
|
-
| [FFI](FFI.md) | Native DSP/RF backends (Volk · Liquid · FFTW · HackRF · RTL-SDR · SoapySDR) |
|
|
66
|
+
| [FFI](FFI.md) | Native DSP/RF backends (Volk · Liquid · FFTW · HackRF · RTL-SDR · **AdalmPluto** · SoapySDR · Stdio) |
|
|
66
67
|
| [Banner](Banner.md) | 15 startup banners |
|
|
67
68
|
|
|
68
69
|
## 🛠️ Meta
|
|
69
70
|
|
|
70
71
|
| | |
|
|
71
72
|
|---|---|
|
|
72
|
-
| [`~/.pwn/` Filesystem Map](Persistence.md) | Every file PWN writes and why |
|
|
73
|
+
| [`~/.pwn/` Filesystem Map](Persistence.md) | Every file PWN writes and why · `PWN::Migrate` state doctor |
|
|
73
74
|
| [Troubleshooting](Troubleshooting.md) | Common errors and fixes |
|
|
74
75
|
| [Contributing](Contributing.md) | Conventions, RuboCop, spec rules |
|
|
75
76
|
|
|
@@ -208,7 +208,8 @@ row as `MISSING` until a key is set.
|
|
|
208
208
|
Everything above is a thin CLI over one autoloaded module:
|
|
209
209
|
|
|
210
210
|
```ruby
|
|
211
|
-
PWN::Setup.check # → { ok:, native_gems_missing:, toolchain_missing:, pkg_manager:, os:, arch: }
|
|
211
|
+
PWN::Setup.check # → { ok:, native_gems_missing:, toolchain_missing:, state:, pkg_manager:, os:, arch: }
|
|
212
|
+
PWN::Setup.migrate(fix: true) # verify + autofix ~/.pwn state files (see PWN::Migrate)
|
|
212
213
|
PWN::Setup.deps(profile: :web, yes: true) # install, then re-check
|
|
213
214
|
PWN::Setup.list_profiles
|
|
214
215
|
PWN::Setup.pkg_manager # → { key: :apt, install: 'sudo apt-get install -y', sudo: true }
|
|
@@ -237,3 +238,27 @@ pwn[CURRENT_VERSION]:004 >>> pwn-ai # launches agent TUI
|
|
|
237
238
|
**Next:** [Configuration](Configuration.md) · [General Usage](General-PWN-Usage.md)
|
|
238
239
|
|
|
239
240
|
[← Home](Home.md)
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
## Upgrading — `~/.pwn` state migration
|
|
245
|
+
|
|
246
|
+
After `gem update pwn`, run the state doctor to verify (and autofix)
|
|
247
|
+
every persisted file under `~/.pwn` against the new release:
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
pwn setup --migrate # verify + apply schema migrations (backup taken)
|
|
251
|
+
pwn setup --migrate --fix # also autofix incompatible files
|
|
252
|
+
pwn setup --migrate --dry-run # report only, write nothing
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
`PWN::Migrate` (autoloaded) stamps `~/.pwn/.schema` with the release
|
|
256
|
+
schema number, checks each state file (`memory.json`, `metrics.json`,
|
|
257
|
+
`mistakes.json`, `learning.jsonl`, `agents.yml`, `cron/jobs.yml`,
|
|
258
|
+
`skills/`, …) against its owning module's loader, and — with `--fix` —
|
|
259
|
+
deep-merges any keys the current `PWN::Config.env_template` added into
|
|
260
|
+
your encrypted `~/.pwn/pwn.yaml` **without overwriting your values**.
|
|
261
|
+
Every run takes a timestamped backup under `~/.pwn/backup/<ts>/` first.
|
|
262
|
+
|
|
263
|
+
The plain `pwn` launcher will also print a one-line drift warning on
|
|
264
|
+
startup whenever `~/.pwn/.schema` predates the running gem.
|
data/lib/pwn/config.rb
CHANGED
|
@@ -7,26 +7,16 @@ module PWN
|
|
|
7
7
|
# Used to manage PWN configuration settings within PWN drivers.
|
|
8
8
|
module Config
|
|
9
9
|
# Supported Method Parameters::
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
#{pwn_env_path}
|
|
21
|
-
2. Your decryptor file will be written to:
|
|
22
|
-
#{pwn_dec_path}
|
|
23
|
-
3. Use the pwn-vault command in the pwn prototyping driver to update:
|
|
24
|
-
#{pwn_env_path}
|
|
25
|
-
4. For optimal security, it's recommended to move:
|
|
26
|
-
#{pwn_dec_path}
|
|
27
|
-
to a secure location and use the --pwn-dec parameter for PWN drivers.
|
|
28
|
-
"
|
|
29
|
-
env = {
|
|
10
|
+
# tmpl = PWN::Config.env_template
|
|
11
|
+
#
|
|
12
|
+
# The canonical current-release ~/.pwn/pwn.yaml shape as a pure Hash
|
|
13
|
+
# (no I/O, no vault write, no puts). Single source of truth used by:
|
|
14
|
+
# * PWN::Config.default_env — seed a fresh ~/.pwn/pwn.yaml
|
|
15
|
+
# * PWN::Migrate.vault_drift — diff a user vault against this release
|
|
16
|
+
# * PWN::Migrate.backfill_vault — deep-merge missing keys UNDER the
|
|
17
|
+
# user values on `pwn setup --migrate --fix`
|
|
18
|
+
public_class_method def self.env_template
|
|
19
|
+
{
|
|
30
20
|
ai: {
|
|
31
21
|
active: 'grok',
|
|
32
22
|
module_reflection: false,
|
|
@@ -174,6 +164,31 @@ module PWN
|
|
|
174
164
|
provider: 'yaml'
|
|
175
165
|
}
|
|
176
166
|
}
|
|
167
|
+
rescue StandardError => e
|
|
168
|
+
raise e
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Supported Method Parameters::
|
|
172
|
+
# env = PWN::Config.default_env(
|
|
173
|
+
# pwn_env_path: 'optional - Path to pwn.yaml file. Defaults to ~/.pwn/pwn.yaml'
|
|
174
|
+
# )
|
|
175
|
+
public_class_method def self.default_env(opts = {})
|
|
176
|
+
pwn_env_path = opts[:pwn_env_path]
|
|
177
|
+
pwn_dec_path = "#{pwn_env_path}.decryptor"
|
|
178
|
+
|
|
179
|
+
puts "
|
|
180
|
+
[*] NOTICE:
|
|
181
|
+
1. Writing minimal PWN::Env to:
|
|
182
|
+
#{pwn_env_path}
|
|
183
|
+
2. Your decryptor file will be written to:
|
|
184
|
+
#{pwn_dec_path}
|
|
185
|
+
3. Use the pwn-vault command in the pwn prototyping driver to update:
|
|
186
|
+
#{pwn_env_path}
|
|
187
|
+
4. For optimal security, it's recommended to move:
|
|
188
|
+
#{pwn_dec_path}
|
|
189
|
+
to a secure location and use the --pwn-dec parameter for PWN drivers.
|
|
190
|
+
"
|
|
191
|
+
env = env_template
|
|
177
192
|
|
|
178
193
|
# Remove beginning colon from key names
|
|
179
194
|
|
|
@@ -393,6 +408,13 @@ module PWN
|
|
|
393
408
|
Pry.config.refresh_pwn_env = false if defined?(Pry)
|
|
394
409
|
|
|
395
410
|
puts "[*] PWN::Env loaded via: #{pwn_env_path}\n"
|
|
411
|
+
|
|
412
|
+
# Upgrade drift — cheap schema-stamp check only (no per-file probes).
|
|
413
|
+
if defined?(PWN::Migrate) && PWN::Migrate.needed?
|
|
414
|
+
puts "[!] ~/.pwn state predates pwn #{PWN::VERSION} (schema " \
|
|
415
|
+
"#{PWN::Migrate.installed_schema} < #{PWN::Migrate::SCHEMA_VERSION}). " \
|
|
416
|
+
'Run `pwn setup --migrate --fix` to autofix (backup taken first).'
|
|
417
|
+
end
|
|
396
418
|
rescue StandardError => e
|
|
397
419
|
raise e
|
|
398
420
|
end
|
data/lib/pwn/migrate.rb
ADDED
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
require 'time'
|
|
7
|
+
|
|
8
|
+
module PWN
|
|
9
|
+
# PWN::Migrate — the ~/.pwn state **doctor** and **auto-migrator**.
|
|
10
|
+
#
|
|
11
|
+
# PWN persists a growing set of files under `~/.pwn` (encrypted config,
|
|
12
|
+
# memory, learning outcomes, metrics, mistakes, extrospection, cron
|
|
13
|
+
# jobs, agents, skills, sessions, swarm buses, …). Each file is owned
|
|
14
|
+
# by a different module and each release can add keys or change shape.
|
|
15
|
+
# A user upgrading `gem install pwn` between two versions could
|
|
16
|
+
# therefore hit `KeyError`, `NoMethodError for nil`, or a silent
|
|
17
|
+
# empty-fallback because their on-disk state predates the new loader.
|
|
18
|
+
#
|
|
19
|
+
# `PWN::Migrate` closes that gap. It is called from:
|
|
20
|
+
#
|
|
21
|
+
# * `pwn setup --migrate` — explicit doctor + autofix
|
|
22
|
+
# * `PWN::Setup.check` — read-only ~/.pwn state section
|
|
23
|
+
# * `PWN::Config.refresh_env` — one-line drift warning on every launch
|
|
24
|
+
#
|
|
25
|
+
# It works by:
|
|
26
|
+
#
|
|
27
|
+
# 1. Stamping `~/.pwn/.schema` with `{ schema:, pwn_version:, at: }`
|
|
28
|
+
# the first time it runs. Any release that changes the on-disk
|
|
29
|
+
# shape of a `~/.pwn` file bumps `SCHEMA_VERSION` here and appends
|
|
30
|
+
# an idempotent lambda to `MIGRATIONS`.
|
|
31
|
+
# 2. Declaratively verifying every state file against its OWNING
|
|
32
|
+
# module's own loader (`STATE_FILES`) — no re-implemented parsers.
|
|
33
|
+
# If the raw file has bytes but the owner returned its
|
|
34
|
+
# empty-fallback, the file is flagged incompatible.
|
|
35
|
+
# 3. Autofixing: timestamped backup → ordered schema migrations →
|
|
36
|
+
# per-file repair (quarantine corrupt / re-seed missing) →
|
|
37
|
+
# `pwn.yaml` deep-key backfill (missing keys from the current
|
|
38
|
+
# `PWN::Config.env_template` are merged in-place under the user's
|
|
39
|
+
# values, then the vault is re-encrypted with the SAME key/iv).
|
|
40
|
+
#
|
|
41
|
+
# Everything is idempotent, dry-run capable, and never overwrites a
|
|
42
|
+
# user-set value.
|
|
43
|
+
module Migrate
|
|
44
|
+
PWN_ROOT = File.join(Dir.home, '.pwn')
|
|
45
|
+
SCHEMA_FILE = File.join(PWN_ROOT, '.schema')
|
|
46
|
+
BACKUP_ROOT = File.join(PWN_ROOT, 'backup')
|
|
47
|
+
QUARANTINE_DIR = File.join(PWN_ROOT, 'quarantine')
|
|
48
|
+
|
|
49
|
+
# Bump this whenever the shape of any file under ~/.pwn changes in a
|
|
50
|
+
# way that requires a one-time transform. Add the transform as an
|
|
51
|
+
# entry in MIGRATIONS keyed by the NEW schema number.
|
|
52
|
+
SCHEMA_VERSION = 1
|
|
53
|
+
|
|
54
|
+
OK = "\e[32mok\e[0m"
|
|
55
|
+
BAD = "\e[31mFAIL\e[0m"
|
|
56
|
+
WARN = "\e[33mdrift\e[0m"
|
|
57
|
+
|
|
58
|
+
# ──────────────────────────────────────────────────────────────────
|
|
59
|
+
# Declarative registry of every persisted ~/.pwn artefact.
|
|
60
|
+
#
|
|
61
|
+
# Each entry knows how to (a) locate itself, (b) ask its owning
|
|
62
|
+
# module to load it, and (c) decide whether the loaded shape is what
|
|
63
|
+
# this pwn version expects. Verifiers are intentionally SHALLOW —
|
|
64
|
+
# they detect "loader gave up and returned the fallback" rather than
|
|
65
|
+
# re-validating every key, so ownership stays with the module.
|
|
66
|
+
#
|
|
67
|
+
# :fix values:
|
|
68
|
+
# :quarantine — move the file aside; owner re-seeds on next write
|
|
69
|
+
# :vault — deep-merge missing keys from PWN::Config.env_template
|
|
70
|
+
# :seed — call the owner's idempotent seeder (dirs / defaults)
|
|
71
|
+
# :jsonl — strip unparsable lines only (keeps good history)
|
|
72
|
+
# :skills — PWN::Config.migrate_legacy_skills
|
|
73
|
+
# ──────────────────────────────────────────────────────────────────
|
|
74
|
+
STATE_FILES = {
|
|
75
|
+
'pwn.yaml' => {
|
|
76
|
+
owner: 'PWN::Config',
|
|
77
|
+
kind: :vault,
|
|
78
|
+
fix: :vault,
|
|
79
|
+
verify: lambda { |p, _raw|
|
|
80
|
+
return { ok: false, why: 'missing' } unless File.file?(p)
|
|
81
|
+
|
|
82
|
+
enc = PWN::Plugins::Vault.file_encrypted?(file: p)
|
|
83
|
+
# Shape (missing keys vs env_template) is checked separately in
|
|
84
|
+
# .vault_drift because it requires the decryptor.
|
|
85
|
+
{ ok: enc, why: enc ? nil : 'not encrypted (run PWN::Plugins::Vault.create)' }
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
'memory.json' => {
|
|
89
|
+
owner: 'PWN::Memory',
|
|
90
|
+
kind: :json,
|
|
91
|
+
fix: :quarantine,
|
|
92
|
+
verify: lambda { |_p, raw|
|
|
93
|
+
m = PWN::Memory.load
|
|
94
|
+
bad = m.reject { |_k, v| v.is_a?(Hash) && v.key?(:value) }
|
|
95
|
+
{ ok: !(m.empty? && raw.to_s.length > 4) && bad.empty?,
|
|
96
|
+
why: bad.any? ? "#{bad.length} entries missing :value" : nil }
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
'memory.idx' => {
|
|
100
|
+
owner: 'PWN::MemoryIndex',
|
|
101
|
+
kind: :blob,
|
|
102
|
+
fix: :quarantine,
|
|
103
|
+
verify: ->(_p, _raw) { { ok: true } }
|
|
104
|
+
},
|
|
105
|
+
'learning.jsonl' => {
|
|
106
|
+
owner: 'PWN::AI::Agent::Learning',
|
|
107
|
+
kind: :jsonl,
|
|
108
|
+
fix: :jsonl,
|
|
109
|
+
verify: :jsonl
|
|
110
|
+
},
|
|
111
|
+
'preferences.jsonl' => {
|
|
112
|
+
owner: 'PWN::AI::Agent::Reward',
|
|
113
|
+
kind: :jsonl,
|
|
114
|
+
fix: :jsonl,
|
|
115
|
+
verify: :jsonl
|
|
116
|
+
},
|
|
117
|
+
'metrics.json' => {
|
|
118
|
+
owner: 'PWN::AI::Agent::Metrics',
|
|
119
|
+
kind: :json,
|
|
120
|
+
fix: :quarantine,
|
|
121
|
+
verify: lambda { |_p, raw|
|
|
122
|
+
m = PWN::AI::Agent::Metrics.load
|
|
123
|
+
fell_back = m == { tools: {}, updated_at: nil } && raw.to_s.length > 4
|
|
124
|
+
{ ok: m.is_a?(Hash) && m.key?(:tools) && !fell_back,
|
|
125
|
+
why: fell_back ? 'loader fell back to empty (unparsable / wrong shape)' : nil }
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
'mistakes.json' => {
|
|
129
|
+
owner: 'PWN::AI::Agent::Mistakes',
|
|
130
|
+
kind: :json,
|
|
131
|
+
fix: :quarantine,
|
|
132
|
+
verify: lambda { |_p, raw|
|
|
133
|
+
m = PWN::AI::Agent::Mistakes.load
|
|
134
|
+
fell_back = m.empty? && raw.to_s.length > 4
|
|
135
|
+
{ ok: m.is_a?(Hash) && !fell_back, why: fell_back ? 'loader fell back to {}' : nil }
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
'extrospection.json' => {
|
|
139
|
+
owner: 'PWN::AI::Agent::Extrospection',
|
|
140
|
+
kind: :json,
|
|
141
|
+
fix: :quarantine,
|
|
142
|
+
verify: lambda { |_p, raw|
|
|
143
|
+
m = PWN::AI::Agent::Extrospection.load
|
|
144
|
+
shape = m.is_a?(Hash) && m.key?(:observations) && m[:observations].is_a?(Array)
|
|
145
|
+
fell_back = shape && m[:snapshot].to_h.empty? && m[:observations].empty? && raw.to_s.length > 8
|
|
146
|
+
{ ok: shape && !fell_back, why: shape ? nil : 'missing :observations array' }
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
'reward_sentinel.json' => {
|
|
150
|
+
owner: 'PWN::AI::Agent::Reward',
|
|
151
|
+
kind: :json,
|
|
152
|
+
fix: :quarantine,
|
|
153
|
+
verify: ->(_p, _raw) { { ok: true } }
|
|
154
|
+
},
|
|
155
|
+
'agents.yml' => {
|
|
156
|
+
owner: 'PWN::AI::Agent::Swarm',
|
|
157
|
+
kind: :yaml,
|
|
158
|
+
fix: :quarantine,
|
|
159
|
+
verify: lambda { |_p, raw|
|
|
160
|
+
m = PWN::AI::Agent::Swarm.personas
|
|
161
|
+
fell_back = m.empty? && raw.to_s.strip.length > 4
|
|
162
|
+
bad = m.reject { |_k, v| v.is_a?(Hash) && v[:role] }
|
|
163
|
+
{ ok: !fell_back && bad.empty?,
|
|
164
|
+
why: (fell_back && 'loader fell back to {}') || (bad.any? && "#{bad.length} personas missing :role") || nil }
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
'cron/jobs.yml' => {
|
|
168
|
+
owner: 'PWN::Cron',
|
|
169
|
+
kind: :yaml,
|
|
170
|
+
fix: :seed,
|
|
171
|
+
verify: lambda { |_p, raw|
|
|
172
|
+
j = PWN::Cron.list
|
|
173
|
+
fell_back = j.empty? && raw.to_s.strip.length > 8
|
|
174
|
+
bad = j.reject { |_id, v| v.is_a?(Hash) && v[:schedule] }
|
|
175
|
+
{ ok: !fell_back && bad.empty?,
|
|
176
|
+
why: (fell_back && 'loader fell back to {}') || (bad.any? && "#{bad.length} jobs missing :schedule") || nil }
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
'skills/' => {
|
|
180
|
+
owner: 'PWN::Config',
|
|
181
|
+
kind: :dir,
|
|
182
|
+
fix: :skills,
|
|
183
|
+
verify: lambda { |p, _raw|
|
|
184
|
+
return { ok: true } unless File.directory?(p)
|
|
185
|
+
|
|
186
|
+
legacy = Dir.glob(File.join(p, '*.{rb,md,txt,skill,yml,yaml}')).length
|
|
187
|
+
{ ok: legacy.zero?, why: legacy.zero? ? nil : "#{legacy} legacy flat skill file(s)" }
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
'sessions/' => { owner: 'PWN::Sessions', kind: :dir, fix: :seed, verify: ->(_p, _r) { { ok: true } } },
|
|
191
|
+
'swarm/' => { owner: 'PWN::AI::Agent::Swarm', kind: :dir, fix: :seed, verify: ->(_p, _r) { { ok: true } } },
|
|
192
|
+
'curriculum/' => { owner: 'PWN::AI::Agent::Curriculum', kind: :dir, fix: :seed, verify: ->(_p, _r) { { ok: true } } },
|
|
193
|
+
'finetune/' => { owner: 'PWN::AI::Agent::Curriculum', kind: :dir, fix: :seed, verify: ->(_p, _r) { { ok: true } } },
|
|
194
|
+
'extrospection/' => { owner: 'PWN::AI::Agent::Extrospection', kind: :dir, fix: :seed, verify: ->(_p, _r) { { ok: true } } }
|
|
195
|
+
}.freeze
|
|
196
|
+
|
|
197
|
+
# Ordered, idempotent one-shot transforms. Key == the schema number
|
|
198
|
+
# a ~/.pwn tree is AT after the lambda runs. Add a new entry every
|
|
199
|
+
# time SCHEMA_VERSION is bumped. Each lambda receives (root, io).
|
|
200
|
+
MIGRATIONS = {
|
|
201
|
+
1 => lambda { |root, io|
|
|
202
|
+
# v0 → v1 : agentskills.io directory layout + seeded RL cron jobs
|
|
203
|
+
# + ensure all state subdirs exist.
|
|
204
|
+
%w[skills sessions swarm cron curriculum finetune extrospection].each do |d|
|
|
205
|
+
FileUtils.mkdir_p(File.join(root, d))
|
|
206
|
+
end
|
|
207
|
+
r = begin
|
|
208
|
+
PWN::Config.migrate_legacy_skills
|
|
209
|
+
rescue StandardError
|
|
210
|
+
{ migrated: 0 }
|
|
211
|
+
end
|
|
212
|
+
io.puts " · migrate_legacy_skills → #{r[:migrated]} converted" if r[:migrated].to_i.positive?
|
|
213
|
+
PWN::Cron.install_defaults if defined?(PWN::Cron)
|
|
214
|
+
}
|
|
215
|
+
}.freeze
|
|
216
|
+
|
|
217
|
+
# ──────────────────────────────────────────────────────────────────
|
|
218
|
+
# public api
|
|
219
|
+
# ──────────────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
# Supported Method Parameters::
|
|
222
|
+
# schema = PWN::Migrate.installed_schema
|
|
223
|
+
#
|
|
224
|
+
# → Integer schema number stamped in ~/.pwn/.schema (0 when absent).
|
|
225
|
+
|
|
226
|
+
public_class_method def self.installed_schema
|
|
227
|
+
return 0 unless File.file?(SCHEMA_FILE)
|
|
228
|
+
|
|
229
|
+
JSON.parse(File.read(SCHEMA_FILE))['schema'].to_i
|
|
230
|
+
rescue StandardError
|
|
231
|
+
0
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Supported Method Parameters::
|
|
235
|
+
# bool = PWN::Migrate.needed?
|
|
236
|
+
#
|
|
237
|
+
# Cheap predicate for PWN::Config.refresh_env — schema stamp only,
|
|
238
|
+
# no per-file probes, so it adds ~0 to launch time.
|
|
239
|
+
|
|
240
|
+
public_class_method def self.needed?
|
|
241
|
+
File.directory?(PWN_ROOT) && installed_schema < SCHEMA_VERSION
|
|
242
|
+
rescue StandardError
|
|
243
|
+
false
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Supported Method Parameters::
|
|
247
|
+
# rows = PWN::Migrate.status
|
|
248
|
+
#
|
|
249
|
+
# Per-file compatibility report — the machine-readable half of
|
|
250
|
+
# `.check`. Never writes. Never raises (each verifier is rescued).
|
|
251
|
+
|
|
252
|
+
public_class_method def self.status
|
|
253
|
+
files = STATE_FILES.map do |rel, meta|
|
|
254
|
+
path = File.join(PWN_ROOT, rel)
|
|
255
|
+
exists = meta[:kind] == :dir ? File.directory?(path) : File.file?(path)
|
|
256
|
+
raw = exists && meta[:kind] != :dir ? safe_read(path: path) : nil
|
|
257
|
+
v = verify_one(path: path, raw: raw, meta: meta)
|
|
258
|
+
{
|
|
259
|
+
rel: rel, path: path, owner: meta[:owner], kind: meta[:kind],
|
|
260
|
+
exists: exists, size: exists ? File.size?(path) : nil,
|
|
261
|
+
ok: v[:ok], why: v[:why], fix: meta[:fix]
|
|
262
|
+
}
|
|
263
|
+
end
|
|
264
|
+
{
|
|
265
|
+
root: PWN_ROOT,
|
|
266
|
+
schema_installed: installed_schema,
|
|
267
|
+
schema_current: SCHEMA_VERSION,
|
|
268
|
+
pwn_version: (defined?(PWN::VERSION) ? PWN::VERSION : nil),
|
|
269
|
+
needs_migration: needed?,
|
|
270
|
+
files: files,
|
|
271
|
+
incompatible: files.reject { |f| f[:ok] }
|
|
272
|
+
}
|
|
273
|
+
rescue StandardError => e
|
|
274
|
+
raise e
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Supported Method Parameters::
|
|
278
|
+
# PWN::Migrate.check(
|
|
279
|
+
# io: 'optional - IO to write the report to (default $stdout)'
|
|
280
|
+
# )
|
|
281
|
+
#
|
|
282
|
+
# Human-readable ~/.pwn state doctor. Read-only. Called from
|
|
283
|
+
# PWN::Setup.check so `pwn setup` shows this alongside the
|
|
284
|
+
# native-gem / toolchain report.
|
|
285
|
+
|
|
286
|
+
public_class_method def self.check(opts = {})
|
|
287
|
+
io = opts[:io] || $stdout
|
|
288
|
+
st = status
|
|
289
|
+
|
|
290
|
+
io.puts "~/.pwn state schema #{st[:schema_installed]} → #{st[:schema_current]} " \
|
|
291
|
+
"#{st[:needs_migration] ? "(#{WARN} — run `pwn setup --migrate`)" : "(#{OK})"}"
|
|
292
|
+
st[:files].each do |f|
|
|
293
|
+
mark = f[:ok] ? OK : BAD
|
|
294
|
+
note = f[:ok] ? '' : "→ #{f[:why]} [fix: #{f[:fix]}]"
|
|
295
|
+
sz = f[:exists] ? human_size(bytes: f[:size]) : '—'
|
|
296
|
+
io.puts " #{f[:rel].ljust(22)} #{mark} #{sz.to_s.ljust(8)} #{f[:owner].to_s.ljust(30)} #{note}"
|
|
297
|
+
end
|
|
298
|
+
unless st[:incompatible].empty?
|
|
299
|
+
io.puts
|
|
300
|
+
io.puts "#{st[:incompatible].length} file(s) incompatible with pwn #{st[:pwn_version]} — " \
|
|
301
|
+
'run `pwn setup --migrate --fix` to autofix (a timestamped backup is taken first).'
|
|
302
|
+
end
|
|
303
|
+
st
|
|
304
|
+
rescue StandardError => e
|
|
305
|
+
raise e
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Supported Method Parameters::
|
|
309
|
+
# report = PWN::Migrate.run(
|
|
310
|
+
# fix: 'optional - also autofix incompatible files (default: schema-migrations only)',
|
|
311
|
+
# backup: 'optional - take timestamped backup of ~/.pwn first (default true)',
|
|
312
|
+
# dry_run: 'optional - report what WOULD happen, write nothing (default false)',
|
|
313
|
+
# key: 'optional - vault key (default: read ~/.pwn/pwn.yaml.decryptor)',
|
|
314
|
+
# iv: 'optional - vault iv (default: read ~/.pwn/pwn.yaml.decryptor)',
|
|
315
|
+
# io: 'optional - IO to write to (default $stdout)'
|
|
316
|
+
# )
|
|
317
|
+
#
|
|
318
|
+
# 1. backup 2. schema migrations installed→current 3. per-file
|
|
319
|
+
# autofix (when fix:true) 4. pwn.yaml key backfill (when fix:true
|
|
320
|
+
# and decryptor available) 5. stamp .schema.
|
|
321
|
+
|
|
322
|
+
public_class_method def self.run(opts = {})
|
|
323
|
+
fix = opts[:fix] ? true : false
|
|
324
|
+
dry_run = opts[:dry_run] ? true : false
|
|
325
|
+
backup = opts.fetch(:backup, true)
|
|
326
|
+
io = opts[:io] || $stdout
|
|
327
|
+
|
|
328
|
+
FileUtils.mkdir_p(PWN_ROOT)
|
|
329
|
+
before = status
|
|
330
|
+
io.puts "PWN::Migrate — pwn #{before[:pwn_version]} · ~/.pwn schema " \
|
|
331
|
+
"#{before[:schema_installed]} → #{before[:schema_current]}" \
|
|
332
|
+
"#{' (dry-run)' if dry_run}"
|
|
333
|
+
|
|
334
|
+
bpath = nil
|
|
335
|
+
if backup && !dry_run && File.directory?(PWN_ROOT)
|
|
336
|
+
bpath = backup!
|
|
337
|
+
io.puts " backup : #{bpath}"
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# ── ordered schema migrations ────────────────────────────────────
|
|
341
|
+
applied = []
|
|
342
|
+
((before[:schema_installed] + 1)..SCHEMA_VERSION).each do |n|
|
|
343
|
+
m = MIGRATIONS[n]
|
|
344
|
+
next unless m
|
|
345
|
+
|
|
346
|
+
io.puts " migrate : v#{n - 1} → v#{n}"
|
|
347
|
+
m.call(PWN_ROOT, io) unless dry_run
|
|
348
|
+
applied << n
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# ── per-file autofix ─────────────────────────────────────────────
|
|
352
|
+
fixed = []
|
|
353
|
+
if fix
|
|
354
|
+
before[:incompatible].each do |f|
|
|
355
|
+
io.puts " fix : #{f[:rel].ljust(22)} [#{f[:fix]}] #{f[:why]}"
|
|
356
|
+
next if dry_run
|
|
357
|
+
|
|
358
|
+
fixed << f.merge(result: apply_fix(file: f))
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# pwn.yaml key backfill runs even when the vault verifier passed —
|
|
362
|
+
# "encrypted" ≠ "has every key this release added".
|
|
363
|
+
vd = backfill_vault(key: opts[:key], iv: opts[:iv], dry_run: dry_run, io: io)
|
|
364
|
+
fixed << vd if vd && vd[:added].to_i.positive?
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
stamp! unless dry_run
|
|
368
|
+
|
|
369
|
+
after = status
|
|
370
|
+
io.puts
|
|
371
|
+
io.puts " result : #{after[:incompatible].length} incompatible remaining " \
|
|
372
|
+
"(was #{before[:incompatible].length}) · schema now #{after[:schema_installed]}"
|
|
373
|
+
|
|
374
|
+
{ backup: bpath, applied_migrations: applied, fixed: fixed,
|
|
375
|
+
before: before, after: after, dry_run: dry_run }
|
|
376
|
+
rescue StandardError => e
|
|
377
|
+
raise e
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Supported Method Parameters::
|
|
381
|
+
# drift = PWN::Migrate.vault_drift(
|
|
382
|
+
# key: 'optional - vault key', iv: 'optional - vault iv'
|
|
383
|
+
# )
|
|
384
|
+
#
|
|
385
|
+
# Deep-diff the user's decrypted pwn.yaml against the current
|
|
386
|
+
# PWN::Config.env_template. → { missing: [dotted.paths], user: {…} }
|
|
387
|
+
# Returns nil when the decryptor is unavailable (never prompts).
|
|
388
|
+
|
|
389
|
+
public_class_method def self.vault_drift(opts = {})
|
|
390
|
+
key, iv = decryptor(opts)
|
|
391
|
+
return nil unless key && iv
|
|
392
|
+
|
|
393
|
+
yaml = File.join(PWN_ROOT, 'pwn.yaml')
|
|
394
|
+
return nil unless File.file?(yaml) && PWN::Plugins::Vault.file_encrypted?(file: yaml)
|
|
395
|
+
|
|
396
|
+
user = PWN::Plugins::Vault.dump(file: yaml, key: key, iv: iv)
|
|
397
|
+
tmpl = env_template
|
|
398
|
+
{ missing: missing_paths(tmpl: tmpl, user: user), user: user, template: tmpl }
|
|
399
|
+
rescue StandardError
|
|
400
|
+
nil
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Supported Method Parameters::
|
|
404
|
+
# r = PWN::Migrate.backfill_vault(
|
|
405
|
+
# key: 'optional', iv: 'optional', dry_run: false, io: $stdout
|
|
406
|
+
# )
|
|
407
|
+
#
|
|
408
|
+
# Decrypt → deep-merge template UNDER user (never overwrites) →
|
|
409
|
+
# re-encrypt with the SAME key/iv. This is the fix for the #1
|
|
410
|
+
# upgrade break: `NoMethodError: undefined method '[]' for nil` when
|
|
411
|
+
# a release added `env[:ai][:new_engine][:…]` and the user's vault
|
|
412
|
+
# predates it.
|
|
413
|
+
|
|
414
|
+
public_class_method def self.backfill_vault(opts = {})
|
|
415
|
+
io = opts[:io] || $stdout
|
|
416
|
+
dry_run = opts[:dry_run] ? true : false
|
|
417
|
+
d = vault_drift(key: opts[:key], iv: opts[:iv])
|
|
418
|
+
unless d
|
|
419
|
+
io.puts ' vault : skipped (no decryptor available — run with --pwn-dec or set key:/iv:)'
|
|
420
|
+
return nil
|
|
421
|
+
end
|
|
422
|
+
if d[:missing].empty?
|
|
423
|
+
io.puts " vault : #{OK} (no missing keys vs PWN::Config.env_template)"
|
|
424
|
+
return { rel: 'pwn.yaml', added: 0, missing: [] }
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
io.puts " vault : #{WARN} #{d[:missing].length} missing key(s) — backfilling under user values"
|
|
428
|
+
d[:missing].first(8).each { |p| io.puts " + #{p}" }
|
|
429
|
+
io.puts " … (+#{d[:missing].length - 8} more)" if d[:missing].length > 8
|
|
430
|
+
return { rel: 'pwn.yaml', added: d[:missing].length, missing: d[:missing], dry_run: true } if dry_run
|
|
431
|
+
|
|
432
|
+
merged = deep_defaults(user: d[:user], tmpl: d[:template])
|
|
433
|
+
yaml = File.join(PWN_ROOT, 'pwn.yaml')
|
|
434
|
+
key, iv = decryptor(opts)
|
|
435
|
+
# Same colon-stripping default_env uses so the file stays diff-clean.
|
|
436
|
+
File.write(yaml, YAML.dump(merged).gsub(/^(\s*):/, '\1'))
|
|
437
|
+
File.chmod(0o600, yaml)
|
|
438
|
+
PWN::Plugins::Vault.encrypt(file: yaml, key: key, iv: iv)
|
|
439
|
+
|
|
440
|
+
{ rel: 'pwn.yaml', added: d[:missing].length, missing: d[:missing] }
|
|
441
|
+
rescue StandardError => e
|
|
442
|
+
io.puts " vault : #{BAD} #{e.class}: #{e.message}"
|
|
443
|
+
nil
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# Supported Method Parameters::
|
|
447
|
+
# tmpl = PWN::Migrate.env_template
|
|
448
|
+
#
|
|
449
|
+
# The canonical current-release pwn.yaml shape, WITHOUT the I/O
|
|
450
|
+
# side-effects of PWN::Config.default_env. Prefers
|
|
451
|
+
# PWN::Config.env_template when that refactor is present.
|
|
452
|
+
|
|
453
|
+
public_class_method def self.env_template
|
|
454
|
+
return PWN::Config.env_template if PWN::Config.respond_to?(:env_template)
|
|
455
|
+
|
|
456
|
+
# Fallback: intercept default_env's write path. Never reached once
|
|
457
|
+
# PWN::Config.env_template ships, but keeps Migrate self-sufficient.
|
|
458
|
+
{}
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# ──────────────────────────────────────────────────────────────────
|
|
462
|
+
# internals
|
|
463
|
+
# ──────────────────────────────────────────────────────────────────
|
|
464
|
+
|
|
465
|
+
private_class_method def self.verify_one(opts = {})
|
|
466
|
+
meta = opts[:meta]
|
|
467
|
+
path = opts[:path]
|
|
468
|
+
raw = opts[:raw]
|
|
469
|
+
# Nonexistent leaf files are fine — owners lazily seed on first
|
|
470
|
+
# write. A missing pwn.yaml IS a problem though (kind :vault).
|
|
471
|
+
return { ok: true } if !File.exist?(path) && meta[:kind] != :vault
|
|
472
|
+
|
|
473
|
+
# Raw parseability first (owners rescue → fallback so we'd never see it).
|
|
474
|
+
case meta[:kind]
|
|
475
|
+
when :json
|
|
476
|
+
JSON.parse(raw.to_s) if raw
|
|
477
|
+
when :yaml
|
|
478
|
+
YAML.safe_load(raw.to_s, permitted_classes: [Symbol, Time, Date], aliases: true) if raw
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
v = meta[:verify]
|
|
482
|
+
return jsonl_verify(path: path) if v == :jsonl
|
|
483
|
+
|
|
484
|
+
v.call(path, raw)
|
|
485
|
+
rescue JSON::ParserError, Psych::SyntaxError => e
|
|
486
|
+
{ ok: false, why: "unparsable #{meta[:kind]}: #{e.message[0, 60]}" }
|
|
487
|
+
rescue StandardError => e
|
|
488
|
+
{ ok: false, why: "#{e.class}: #{e.message[0, 80]}" }
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
private_class_method def self.jsonl_verify(opts = {})
|
|
492
|
+
path = opts[:path]
|
|
493
|
+
total = 0
|
|
494
|
+
bad = 0
|
|
495
|
+
File.foreach(path) do |l|
|
|
496
|
+
next if l.strip.empty?
|
|
497
|
+
|
|
498
|
+
total += 1
|
|
499
|
+
JSON.parse(l)
|
|
500
|
+
rescue StandardError
|
|
501
|
+
bad += 1
|
|
502
|
+
end
|
|
503
|
+
{ ok: bad.zero?, why: bad.zero? ? nil : "#{bad}/#{total} lines unparsable" }
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
private_class_method def self.apply_fix(opts = {})
|
|
507
|
+
f = opts[:file]
|
|
508
|
+
case f[:fix]
|
|
509
|
+
when :quarantine
|
|
510
|
+
quarantine!(rel: f[:rel])
|
|
511
|
+
when :jsonl
|
|
512
|
+
strip_bad_jsonl!(rel: f[:rel])
|
|
513
|
+
when :skills
|
|
514
|
+
PWN::Config.migrate_legacy_skills
|
|
515
|
+
when :seed
|
|
516
|
+
FileUtils.mkdir_p(File.join(PWN_ROOT, f[:rel])) if f[:kind] == :dir
|
|
517
|
+
PWN::Cron.install_defaults if f[:rel].start_with?('cron/') && defined?(PWN::Cron)
|
|
518
|
+
:seeded
|
|
519
|
+
when :vault
|
|
520
|
+
:deferred_to_backfill
|
|
521
|
+
else
|
|
522
|
+
:noop
|
|
523
|
+
end
|
|
524
|
+
rescue StandardError => e
|
|
525
|
+
"#{e.class}: #{e.message}"
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
private_class_method def self.quarantine!(opts = {})
|
|
529
|
+
src = File.join(PWN_ROOT, opts[:rel])
|
|
530
|
+
return :absent unless File.exist?(src)
|
|
531
|
+
|
|
532
|
+
dst = File.join(QUARANTINE_DIR, Time.now.utc.strftime('%Y%m%d_%H%M%S'))
|
|
533
|
+
FileUtils.mkdir_p(dst)
|
|
534
|
+
FileUtils.mv(src, File.join(dst, File.basename(opts[:rel])))
|
|
535
|
+
"quarantined → #{dst}"
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
private_class_method def self.strip_bad_jsonl!(opts = {})
|
|
539
|
+
path = File.join(PWN_ROOT, opts[:rel])
|
|
540
|
+
return :absent unless File.file?(path)
|
|
541
|
+
|
|
542
|
+
good = []
|
|
543
|
+
bad = 0
|
|
544
|
+
File.foreach(path) do |l|
|
|
545
|
+
next if l.strip.empty?
|
|
546
|
+
|
|
547
|
+
JSON.parse(l)
|
|
548
|
+
good << l
|
|
549
|
+
rescue StandardError
|
|
550
|
+
bad += 1
|
|
551
|
+
end
|
|
552
|
+
File.write(path, good.join)
|
|
553
|
+
"dropped #{bad} bad line(s), kept #{good.length}"
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
private_class_method def self.backup!
|
|
557
|
+
ts = Time.now.utc.strftime('%Y%m%d_%H%M%S')
|
|
558
|
+
dst = File.join(BACKUP_ROOT, ts)
|
|
559
|
+
FileUtils.mkdir_p(dst)
|
|
560
|
+
Dir.children(PWN_ROOT).each do |c|
|
|
561
|
+
next if %w[backup quarantine sessions swarm].include?(c) # large / regenerable
|
|
562
|
+
|
|
563
|
+
src = File.join(PWN_ROOT, c)
|
|
564
|
+
FileUtils.cp_r(src, dst)
|
|
565
|
+
rescue StandardError
|
|
566
|
+
next
|
|
567
|
+
end
|
|
568
|
+
dst
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
private_class_method def self.stamp!
|
|
572
|
+
FileUtils.mkdir_p(PWN_ROOT)
|
|
573
|
+
File.write(
|
|
574
|
+
SCHEMA_FILE,
|
|
575
|
+
JSON.pretty_generate(
|
|
576
|
+
schema: SCHEMA_VERSION,
|
|
577
|
+
pwn_version: (defined?(PWN::VERSION) ? PWN::VERSION : nil),
|
|
578
|
+
at: Time.now.utc.iso8601
|
|
579
|
+
)
|
|
580
|
+
)
|
|
581
|
+
SCHEMA_VERSION
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
private_class_method def self.decryptor(opts = {})
|
|
585
|
+
key = opts[:key]
|
|
586
|
+
iv = opts[:iv]
|
|
587
|
+
return [key, iv] if key && iv
|
|
588
|
+
|
|
589
|
+
dec = File.join(PWN_ROOT, 'pwn.yaml.decryptor')
|
|
590
|
+
return [nil, nil] unless File.file?(dec)
|
|
591
|
+
|
|
592
|
+
d = YAML.load_file(dec, symbolize_names: true)
|
|
593
|
+
[d[:key], d[:iv]]
|
|
594
|
+
rescue StandardError
|
|
595
|
+
[nil, nil]
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
private_class_method def self.safe_read(opts = {})
|
|
599
|
+
path = opts[:path]
|
|
600
|
+
File.size(path) > 5_000_000 ? nil : File.read(path)
|
|
601
|
+
rescue StandardError
|
|
602
|
+
nil
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
private_class_method def self.human_size(opts = {})
|
|
606
|
+
bytes = opts[:bytes]
|
|
607
|
+
return '' if bytes.nil?
|
|
608
|
+
|
|
609
|
+
u = %w[B K M G]
|
|
610
|
+
i = 0
|
|
611
|
+
n = bytes.to_f
|
|
612
|
+
while n >= 1024 && i < u.length - 1
|
|
613
|
+
n /= 1024
|
|
614
|
+
i += 1
|
|
615
|
+
end
|
|
616
|
+
format('%<n>.4g%<u>s', n: n, u: u[i])
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
# template UNDER user — user values always win; template only fills
|
|
620
|
+
# holes. Arrays are treated as leaves (never element-merged).
|
|
621
|
+
private_class_method def self.deep_defaults(opts = {})
|
|
622
|
+
user = opts[:user]
|
|
623
|
+
tmpl = opts[:tmpl]
|
|
624
|
+
return user unless tmpl.is_a?(Hash)
|
|
625
|
+
return tmpl unless user.is_a?(Hash)
|
|
626
|
+
|
|
627
|
+
out = user.dup
|
|
628
|
+
tmpl.each do |k, tv|
|
|
629
|
+
uv = out.key?(k) ? out[k] : out[k.to_s]
|
|
630
|
+
out[k] = if tv.is_a?(Hash)
|
|
631
|
+
deep_defaults(user: uv, tmpl: tv)
|
|
632
|
+
elsif uv.nil? && !out.key?(k) && !out.key?(k.to_s)
|
|
633
|
+
tv
|
|
634
|
+
else
|
|
635
|
+
uv
|
|
636
|
+
end
|
|
637
|
+
end
|
|
638
|
+
out
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
private_class_method def self.missing_paths(opts = {})
|
|
642
|
+
tmpl = opts[:tmpl]
|
|
643
|
+
user = opts[:user]
|
|
644
|
+
prefix = opts[:prefix] || ''
|
|
645
|
+
out = []
|
|
646
|
+
return out unless tmpl.is_a?(Hash)
|
|
647
|
+
|
|
648
|
+
tmpl.each do |k, tv|
|
|
649
|
+
dotted = prefix.empty? ? k.to_s : "#{prefix}.#{k}"
|
|
650
|
+
uv = nil
|
|
651
|
+
if user.is_a?(Hash)
|
|
652
|
+
uv = user[k]
|
|
653
|
+
uv = user[k.to_s] if uv.nil?
|
|
654
|
+
end
|
|
655
|
+
if tv.is_a?(Hash)
|
|
656
|
+
if uv.is_a?(Hash)
|
|
657
|
+
out.concat(missing_paths(tmpl: tv, user: uv, prefix: dotted))
|
|
658
|
+
else
|
|
659
|
+
out << dotted
|
|
660
|
+
end
|
|
661
|
+
elsif !user.is_a?(Hash) || (!user.key?(k) && !user.key?(k.to_s))
|
|
662
|
+
out << dotted
|
|
663
|
+
end
|
|
664
|
+
end
|
|
665
|
+
out
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
# Author(s):: 0day Inc. <support@0dayinc.com>
|
|
669
|
+
|
|
670
|
+
public_class_method def self.authors
|
|
671
|
+
"AUTHOR(S):
|
|
672
|
+
0day Inc. <support@0dayinc.com>
|
|
673
|
+
"
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
# Display Usage for this Module
|
|
677
|
+
|
|
678
|
+
public_class_method def self.help
|
|
679
|
+
puts "USAGE:
|
|
680
|
+
# Is ~/.pwn older than this pwn release's schema?
|
|
681
|
+
#{self}.needed? # => true/false (cheap; used by refresh_env)
|
|
682
|
+
|
|
683
|
+
# Read-only per-file compatibility report.
|
|
684
|
+
#{self}.status # => { schema_installed:, schema_current:, files:[…], incompatible:[…] }
|
|
685
|
+
#{self}.check # human-readable to $stdout
|
|
686
|
+
|
|
687
|
+
# Apply schema migrations + (optionally) autofix incompatible files.
|
|
688
|
+
# Always takes a timestamped backup under ~/.pwn/backup/<ts>/ first.
|
|
689
|
+
#{self}.run(
|
|
690
|
+
fix: 'optional - also autofix per-file (default false)',
|
|
691
|
+
backup: 'optional - default true',
|
|
692
|
+
dry_run: 'optional - default false',
|
|
693
|
+
key: 'optional - vault key (default: ~/.pwn/pwn.yaml.decryptor)',
|
|
694
|
+
iv: 'optional - vault iv'
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
# pwn.yaml specifically — deep-merge missing keys from the
|
|
698
|
+
# current release template UNDER the user's values, re-encrypt.
|
|
699
|
+
#{self}.vault_drift # => { missing: [dotted.paths], … } or nil
|
|
700
|
+
#{self}.backfill_vault
|
|
701
|
+
|
|
702
|
+
# Data:
|
|
703
|
+
#{self}::SCHEMA_VERSION # bump when a ~/.pwn file shape changes
|
|
704
|
+
#{self}::STATE_FILES # rel-path → owner → verifier → fix
|
|
705
|
+
#{self}::MIGRATIONS # ordered idempotent transforms
|
|
706
|
+
|
|
707
|
+
# From the shell:
|
|
708
|
+
pwn setup --migrate # == #{self}.run
|
|
709
|
+
pwn setup --migrate --fix # == #{self}.run(fix: true)
|
|
710
|
+
pwn setup --migrate --dry-run
|
|
711
|
+
|
|
712
|
+
#{self}.authors
|
|
713
|
+
"
|
|
714
|
+
end
|
|
715
|
+
end
|
|
716
|
+
end
|
data/lib/pwn/setup.rb
CHANGED
|
@@ -372,6 +372,13 @@ module PWN
|
|
|
372
372
|
end
|
|
373
373
|
io.puts
|
|
374
374
|
|
|
375
|
+
# ~/.pwn state ----------------------------------------------------
|
|
376
|
+
migrate = nil
|
|
377
|
+
if defined?(PWN::Migrate)
|
|
378
|
+
migrate = PWN::Migrate.check(io: io)
|
|
379
|
+
io.puts
|
|
380
|
+
end
|
|
381
|
+
|
|
375
382
|
total = NATIVE_GEMS.size + TOOLCHAIN.size
|
|
376
383
|
missing = gem_missing.size + bin_missing.size
|
|
377
384
|
io.puts "#{total - missing} / #{total} capabilities usable · #{missing} degraded"
|
|
@@ -381,8 +388,9 @@ module PWN
|
|
|
381
388
|
io.puts ' `pwn setup --profile <name>` for a subset. See `pwn setup --list-profiles`.'
|
|
382
389
|
end
|
|
383
390
|
|
|
384
|
-
{ ok: missing.zero
|
|
385
|
-
|
|
391
|
+
{ ok: missing.zero? && Array(migrate && migrate[:incompatible]).empty?,
|
|
392
|
+
native_gems_missing: gem_missing, toolchain_missing: bin_missing,
|
|
393
|
+
state: migrate, pkg_manager: pm[:key], os: os, arch: arch }
|
|
386
394
|
rescue StandardError => e
|
|
387
395
|
raise e
|
|
388
396
|
end
|
|
@@ -493,6 +501,28 @@ module PWN
|
|
|
493
501
|
raise e
|
|
494
502
|
end
|
|
495
503
|
|
|
504
|
+
# Supported Method Parameters::
|
|
505
|
+
# PWN::Setup.migrate(
|
|
506
|
+
# fix: 'optional - also autofix incompatible ~/.pwn files (default false)',
|
|
507
|
+
# dry_run: 'optional - print what WOULD happen (default false)',
|
|
508
|
+
# yes: 'optional - alias for fix:true (CI-friendly)',
|
|
509
|
+
# io: 'optional - IO to write to (default $stdout)'
|
|
510
|
+
# )
|
|
511
|
+
#
|
|
512
|
+
# Delegate to PWN::Migrate.run — verify every ~/.pwn state file is
|
|
513
|
+
# compatible with THIS pwn release and (optionally) autofix it. A
|
|
514
|
+
# timestamped backup of ~/.pwn is taken first. See `PWN::Migrate.help`.
|
|
515
|
+
|
|
516
|
+
public_class_method def self.migrate(opts = {})
|
|
517
|
+
PWN::Migrate.run(
|
|
518
|
+
fix: opts[:fix] || opts[:yes],
|
|
519
|
+
dry_run: opts[:dry_run],
|
|
520
|
+
io: opts[:io] || $stdout
|
|
521
|
+
)
|
|
522
|
+
rescue StandardError => e
|
|
523
|
+
raise e
|
|
524
|
+
end
|
|
525
|
+
|
|
496
526
|
# ------------------------------------------------------------------
|
|
497
527
|
|
|
498
528
|
private_class_method def self.detect_os_attr(opts = {})
|
|
@@ -585,6 +615,8 @@ module PWN
|
|
|
585
615
|
pwn setup --profile sdr --yes
|
|
586
616
|
pwn setup --list-profiles
|
|
587
617
|
pwn setup --dry-run --profile net
|
|
618
|
+
pwn setup --migrate # verify + upgrade ~/.pwn schema
|
|
619
|
+
pwn setup --migrate --fix # also autofix incompatible files
|
|
588
620
|
|
|
589
621
|
#{self}.authors
|
|
590
622
|
"
|
data/lib/pwn/version.rb
CHANGED
data/lib/pwn.rb
CHANGED
|
@@ -57,9 +57,9 @@ RSpec.describe 'documentation/Installation.md' do
|
|
|
57
57
|
end
|
|
58
58
|
|
|
59
59
|
it 'programmatic API section is truthful' do
|
|
60
|
-
expect(PWN::Setup).to respond_to(:check, :deps, :list_profiles, :pkg_manager)
|
|
60
|
+
expect(PWN::Setup).to respond_to(:check, :deps, :list_profiles, :pkg_manager, :migrate)
|
|
61
61
|
expect(PWN::Setup.check(io: StringIO.new).keys)
|
|
62
|
-
.to eq(%i[ok native_gems_missing toolchain_missing pkg_manager os arch])
|
|
62
|
+
.to eq(%i[ok native_gems_missing toolchain_missing state pkg_manager os arch])
|
|
63
63
|
expect(PWN::Setup.pkg_manager.keys).to eq(%i[key install sudo])
|
|
64
64
|
expect(PWN::Setup.constants).to include(:NATIVE_GEMS, :TOOLCHAIN, :PROFILES)
|
|
65
65
|
end
|
|
@@ -15,6 +15,8 @@ describe PWN::AI::Agent::Mistakes do
|
|
|
15
15
|
|
|
16
16
|
it 'fingerprints, counts and resolves recurring failures' do
|
|
17
17
|
stub_const('PWN::AI::Agent::Mistakes::MISTAKES_FILE', File.join(Dir.mktmpdir, 'mistakes.json'))
|
|
18
|
+
stub_const('PWN::AI::Agent::Reward::PREFERENCES_FILE', File.join(Dir.mktmpdir, 'prefs.jsonl')) if defined?(PWN::AI::Agent::Reward)
|
|
19
|
+
stub_const('PWN::Memory::MEMORY_FILE', File.join(Dir.mktmpdir, 'memory.json')) if defined?(PWN::Memory)
|
|
18
20
|
PWN::AI::Agent::Mistakes.reset
|
|
19
21
|
a = PWN::AI::Agent::Mistakes.record(tool: 'shell', error: 'nmpa: command not found at /tmp/x:42')
|
|
20
22
|
b = PWN::AI::Agent::Mistakes.record(tool: 'shell', error: 'nmpa: command not found at /var/y:99')
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
describe PWN::Migrate do
|
|
6
|
+
it 'should display information for authors' do
|
|
7
|
+
authors_response = PWN::Migrate
|
|
8
|
+
expect(authors_response).to respond_to :authors
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it 'should display information for existing help method' do
|
|
12
|
+
help_response = PWN::Migrate
|
|
13
|
+
expect(help_response).to respond_to :help
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'exposes the schema / state-file data tables' do
|
|
17
|
+
expect(PWN::Migrate::SCHEMA_VERSION).to be_a(Integer)
|
|
18
|
+
expect(PWN::Migrate::STATE_FILES).to be_a(Hash)
|
|
19
|
+
expect(PWN::Migrate::STATE_FILES).to have_key('pwn.yaml')
|
|
20
|
+
expect(PWN::Migrate::MIGRATIONS).to be_a(Hash)
|
|
21
|
+
expect(PWN::Migrate::MIGRATIONS.keys.max).to eq(PWN::Migrate::SCHEMA_VERSION)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'env_template is the pure hash used for vault backfill' do
|
|
25
|
+
t = PWN::Config.env_template
|
|
26
|
+
expect(t).to be_a(Hash)
|
|
27
|
+
expect(t).to have_key(:ai)
|
|
28
|
+
expect(t[:ai]).to have_key(:agent)
|
|
29
|
+
expect(PWN::Migrate.env_template).to eq(t)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# ────────────────────────────────────────────────────────────────────
|
|
33
|
+
# functional round-trip in a tmp sandbox — never touches real ~/.pwn
|
|
34
|
+
# ────────────────────────────────────────────────────────────────────
|
|
35
|
+
context 'sandboxed ~/.pwn', :aggregate_failures do
|
|
36
|
+
include_context 'pwn tmp sandbox'
|
|
37
|
+
|
|
38
|
+
let(:io) { StringIO.new }
|
|
39
|
+
|
|
40
|
+
before do
|
|
41
|
+
# Skills verifier globs the REAL PWN::Config.pwn_skills_path unless
|
|
42
|
+
# redirected — keep it inside the sandbox too.
|
|
43
|
+
allow(PWN::Config).to receive(:pwn_skills_path).and_return(File.join(@tmp, 'skills'))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it '.needed? / .status on a fresh (empty) root' do
|
|
47
|
+
expect(PWN::Migrate.installed_schema).to eq(0)
|
|
48
|
+
expect(PWN::Migrate.needed?).to be true
|
|
49
|
+
|
|
50
|
+
st = PWN::Migrate.status
|
|
51
|
+
expect(st[:schema_installed]).to eq(0)
|
|
52
|
+
expect(st[:schema_current]).to eq(PWN::Migrate::SCHEMA_VERSION)
|
|
53
|
+
expect(st[:files]).to be_an(Array)
|
|
54
|
+
# only pwn.yaml is REQUIRED to exist; every other absent file is ok:true
|
|
55
|
+
missing_vault = st[:files].find { |f| f[:rel] == 'pwn.yaml' }
|
|
56
|
+
expect(missing_vault[:ok]).to be false
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it '.run applies migrations, stamps .schema, and clears needed?' do
|
|
60
|
+
r = PWN::Migrate.run(fix: false, backup: false, io: io)
|
|
61
|
+
expect(r[:applied_migrations]).to include(1)
|
|
62
|
+
expect(File).to exist(File.join(@tmp, '.schema'))
|
|
63
|
+
expect(PWN::Migrate.installed_schema).to eq(PWN::Migrate::SCHEMA_VERSION)
|
|
64
|
+
expect(PWN::Migrate.needed?).to be false
|
|
65
|
+
# migration 1 seeds the state subdirs
|
|
66
|
+
%w[skills sessions swarm cron curriculum finetune extrospection].each do |d|
|
|
67
|
+
expect(File).to be_directory(File.join(@tmp, d))
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it 'detects & autofixes a corrupt JSON store (quarantine)' do
|
|
72
|
+
File.write(File.join(@tmp, 'metrics.json'), '{{{ not json')
|
|
73
|
+
st = PWN::Migrate.status
|
|
74
|
+
row = st[:files].find { |f| f[:rel] == 'metrics.json' }
|
|
75
|
+
expect(row[:ok]).to be false
|
|
76
|
+
expect(row[:why]).to match(/unparsable/)
|
|
77
|
+
|
|
78
|
+
PWN::Migrate.run(fix: true, backup: false, io: io)
|
|
79
|
+
expect(File).not_to exist(File.join(@tmp, 'metrics.json'))
|
|
80
|
+
expect(Dir.glob(File.join(@tmp, 'quarantine', '*', 'metrics.json'))).not_to be_empty
|
|
81
|
+
# owner falls back cleanly after quarantine
|
|
82
|
+
expect(PWN::AI::Agent::Metrics.load).to eq(tools: {}, updated_at: nil)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it 'detects & autofixes bad JSONL lines without losing good history' do
|
|
86
|
+
path = File.join(@tmp, 'learning.jsonl')
|
|
87
|
+
File.write(path, %({"task":"a","success":true}\nNOT JSON\n{"task":"b","success":false}\n))
|
|
88
|
+
row = PWN::Migrate.status[:files].find { |f| f[:rel] == 'learning.jsonl' }
|
|
89
|
+
expect(row[:ok]).to be false
|
|
90
|
+
expect(row[:why]).to match(%r{1/3})
|
|
91
|
+
|
|
92
|
+
PWN::Migrate.run(fix: true, backup: false, io: io)
|
|
93
|
+
kept = File.readlines(path)
|
|
94
|
+
expect(kept.length).to eq(2)
|
|
95
|
+
kept.each { |l| expect { JSON.parse(l) }.not_to raise_error }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it 'detects legacy flat skills and converts them (fix: :skills)' do
|
|
99
|
+
sroot = File.join(@tmp, 'skills')
|
|
100
|
+
FileUtils.mkdir_p(sroot)
|
|
101
|
+
File.write(File.join(sroot, 'legacy_probe.md'), "# legacy\n\nBody line.\n")
|
|
102
|
+
row = PWN::Migrate.status[:files].find { |f| f[:rel] == 'skills/' }
|
|
103
|
+
expect(row[:ok]).to be false
|
|
104
|
+
|
|
105
|
+
PWN::Migrate.run(fix: true, backup: false, io: io)
|
|
106
|
+
expect(File).to exist(File.join(sroot, 'legacy-probe', 'SKILL.md'))
|
|
107
|
+
expect(Dir.glob(File.join(sroot, '*.md'))).to be_empty
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
it 'deep_defaults never overwrites a user value' do
|
|
111
|
+
user = { ai: { active: 'anthropic', anthropic: { key: 'sk-LIVE' } } }
|
|
112
|
+
tmpl = PWN::Config.env_template
|
|
113
|
+
merged = PWN::Migrate.send(:deep_defaults, user: user, tmpl: tmpl)
|
|
114
|
+
expect(merged[:ai][:active]).to eq('anthropic') # user wins
|
|
115
|
+
expect(merged[:ai][:anthropic][:key]).to eq('sk-LIVE') # user wins
|
|
116
|
+
expect(merged[:ai][:anthropic]).to have_key(:model) # backfilled
|
|
117
|
+
expect(merged[:ai]).to have_key(:agent) # whole subtree backfilled
|
|
118
|
+
expect(merged).to have_key(:cron) # top-level backfilled
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it 'missing_paths reports dotted keys absent from a stale user vault' do
|
|
122
|
+
stale = { ai: { active: 'grok', grok: { key: 'x' } } }
|
|
123
|
+
miss = PWN::Migrate.send(:missing_paths, tmpl: PWN::Config.env_template, user: stale)
|
|
124
|
+
expect(miss).to include('ai.agent')
|
|
125
|
+
expect(miss).to include('memory')
|
|
126
|
+
expect(miss).not_to include('ai.active')
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
it 'dry_run writes nothing' do
|
|
130
|
+
r = PWN::Migrate.run(fix: true, backup: true, dry_run: true, io: io)
|
|
131
|
+
expect(r[:dry_run]).to be true
|
|
132
|
+
expect(File).not_to exist(File.join(@tmp, '.schema'))
|
|
133
|
+
expect(Dir.glob(File.join(@tmp, 'backup', '*'))).to be_empty
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
data/spec/spec_helper.rb
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'pwn'
|
|
4
|
+
require 'stringio'
|
|
4
5
|
|
|
5
6
|
Dir[File.join(__dir__, 'support', '**', '*.rb')].each { |f| require f }
|
|
7
|
+
|
|
8
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
9
|
+
# Keep `rake` output clean.
|
|
10
|
+
#
|
|
11
|
+
# RSpec binds its formatter to $stdout at configure-time (before any example
|
|
12
|
+
# runs), so redirecting $stdout / $stderr *inside* each example swallows any
|
|
13
|
+
# incidental `puts` / `print` / `p` / `warn` emitted by the code under test
|
|
14
|
+
# WITHOUT affecting the progress dots or failure reports.
|
|
15
|
+
#
|
|
16
|
+
# Escape hatches:
|
|
17
|
+
# - `PWN_SPEC_VERBOSE=1 rake spec` → no redirection at all
|
|
18
|
+
# - `it '...', :stdout do ... end` → per-example opt-out
|
|
19
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
20
|
+
RSpec.configure do |config|
|
|
21
|
+
next if ENV['PWN_SPEC_VERBOSE']
|
|
22
|
+
|
|
23
|
+
original_stdout = $stdout
|
|
24
|
+
original_stderr = $stderr
|
|
25
|
+
devnull = StringIO.new
|
|
26
|
+
|
|
27
|
+
config.before(:each) do |example|
|
|
28
|
+
next if example.metadata[:stdout]
|
|
29
|
+
|
|
30
|
+
devnull.truncate(0)
|
|
31
|
+
devnull.rewind
|
|
32
|
+
$stdout = devnull
|
|
33
|
+
$stderr = devnull
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
config.after(:each) do
|
|
37
|
+
$stdout = original_stdout
|
|
38
|
+
$stderr = original_stderr
|
|
39
|
+
end
|
|
40
|
+
end
|
data/spec/support/sandbox.rb
CHANGED
|
@@ -49,6 +49,12 @@ RSpec.shared_context 'pwn tmp sandbox' do
|
|
|
49
49
|
stub_const(const, File.join(@tmp, rel)) if Object.const_defined?(owner)
|
|
50
50
|
end
|
|
51
51
|
stub_const('PWN::MemoryIndex::INDEX_FILE', File.join(@tmp, 'memory.idx')) if defined?(PWN::MemoryIndex)
|
|
52
|
+
if defined?(PWN::Migrate)
|
|
53
|
+
stub_const('PWN::Migrate::PWN_ROOT', @tmp)
|
|
54
|
+
stub_const('PWN::Migrate::SCHEMA_FILE', File.join(@tmp, '.schema'))
|
|
55
|
+
stub_const('PWN::Migrate::BACKUP_ROOT', File.join(@tmp, 'backup'))
|
|
56
|
+
stub_const('PWN::Migrate::QUARANTINE_DIR', File.join(@tmp, 'quarantine'))
|
|
57
|
+
end
|
|
52
58
|
|
|
53
59
|
# Controllable env — module_reflection OFF so nothing hits an engine.
|
|
54
60
|
@agent_cfg = {}
|
data/third_party/pwn_rdoc.jsonl
CHANGED
|
@@ -908,6 +908,7 @@
|
|
|
908
908
|
{"messages":[{"role":"user","content":"PWN::Bounty::LifecycleAuthzReplay.write_yaml Usage"},{"role":"assistant","content":"`PWN::Bounty::LifecycleAuthzReplay.write_yaml`: "}]}
|
|
909
909
|
{"messages":[{"role":"user","content":"PWN::Config.authors Usage"},{"role":"assistant","content":"`PWN::Config.authors`: Author(s)\n\n0day Inc. <support@0dayinc.com>\n"}]}
|
|
910
910
|
{"messages":[{"role":"user","content":"PWN::Config.default_env Usage"},{"role":"assistant","content":"`PWN::Config.default_env`: Supported Method Parameters\n\nenv = PWN::Config.default_env(\n\npwn_env_path: 'optional - Path to pwn.yaml file. Defaults to ~/.pwn/pwn.yaml'\n\n)\n"}]}
|
|
911
|
+
{"messages":[{"role":"user","content":"PWN::Config.env_template Usage"},{"role":"assistant","content":"`PWN::Config.env_template`: Supported Method Parameters\n\ntmpl = PWN::Config.env_template\n\nThe canonical current-release ~/.pwn/pwn.yaml shape as a pure Hash (no I/O, no vault write, no puts). Single source of truth used by:\n\n* PWN::Config.default_env — seed a fresh ~/.pwn/pwn.yaml\n* PWN::Migrate.vault_drift — diff a user vault against this release\n* PWN::Migrate.backfill_vault — deep-merge missing keys UNDER the\n user values on `pwn setup --migrate --fix`\n"}]}
|
|
911
912
|
{"messages":[{"role":"user","content":"PWN::Config.help Usage"},{"role":"assistant","content":"`PWN::Config.help`: "}]}
|
|
912
913
|
{"messages":[{"role":"user","content":"PWN::Config.init_driver_options Usage"},{"role":"assistant","content":"`PWN::Config.init_driver_options`: Supported Method Parameters\n\nenv = PWN::Config.init_driver_options\n"}]}
|
|
913
914
|
{"messages":[{"role":"user","content":"PWN::Config.load_memory Usage"},{"role":"assistant","content":"`PWN::Config.load_memory`: Supported Method Parameters\n\nPWN::Config.load_memory\n"}]}
|
|
@@ -1047,6 +1048,28 @@
|
|
|
1047
1048
|
{"messages":[{"role":"user","content":"PWN::MemoryIndex.save_index Usage"},{"role":"assistant","content":"`PWN::MemoryIndex.save_index`: "}]}
|
|
1048
1049
|
{"messages":[{"role":"user","content":"PWN::MemoryIndex.sha Usage"},{"role":"assistant","content":"`PWN::MemoryIndex.sha`: "}]}
|
|
1049
1050
|
{"messages":[{"role":"user","content":"PWN::MemoryIndex.to_context Usage"},{"role":"assistant","content":"`PWN::MemoryIndex.to_context`: Supported Method Parameters\n\nctx = PWN::MemoryIndex.to_context(query:, limit: 6)\n"}]}
|
|
1051
|
+
{"messages":[{"role":"user","content":"PWN::Migrate.apply_fix Usage"},{"role":"assistant","content":"`PWN::Migrate.apply_fix`: "}]}
|
|
1052
|
+
{"messages":[{"role":"user","content":"PWN::Migrate.authors Usage"},{"role":"assistant","content":"`PWN::Migrate.authors`: Author(s)\n\n0day Inc. <support@0dayinc.com>\n"}]}
|
|
1053
|
+
{"messages":[{"role":"user","content":"PWN::Migrate.backfill_vault Usage"},{"role":"assistant","content":"`PWN::Migrate.backfill_vault`: Supported Method Parameters\n\nr = PWN::Migrate.backfill_vault(\n\nkey: 'optional', iv: 'optional', dry_run: false, io: $stdout\n\n)\n\nDecrypt → deep-merge template UNDER user (never overwrites) →re-encrypt with the SAME key/iv. This is the fix for the #1 upgrade break: ‘NoMethodError: undefined method ’[]‘ for nil` when a release added `env[:new_engine]` and the user’s vault predates it.\n"}]}
|
|
1054
|
+
{"messages":[{"role":"user","content":"PWN::Migrate.backup! Usage"},{"role":"assistant","content":"`PWN::Migrate.backup!`: "}]}
|
|
1055
|
+
{"messages":[{"role":"user","content":"PWN::Migrate.check Usage"},{"role":"assistant","content":"`PWN::Migrate.check`: Supported Method Parameters\n\nPWN::Migrate.check(\n\nio: 'optional - IO to write the report to (default $stdout)'\n\n)\n\nHuman-readable ~/.pwn state doctor. Read-only. Called from PWN::Setup.check so ‘pwn setup` shows this alongside the native-gem / toolchain report.\n"}]}
|
|
1056
|
+
{"messages":[{"role":"user","content":"PWN::Migrate.decryptor Usage"},{"role":"assistant","content":"`PWN::Migrate.decryptor`: "}]}
|
|
1057
|
+
{"messages":[{"role":"user","content":"PWN::Migrate.deep_defaults Usage"},{"role":"assistant","content":"`PWN::Migrate.deep_defaults`: "}]}
|
|
1058
|
+
{"messages":[{"role":"user","content":"PWN::Migrate.env_template Usage"},{"role":"assistant","content":"`PWN::Migrate.env_template`: Supported Method Parameters\n\ntmpl = PWN::Migrate.env_template\n\nThe canonical current-release pwn.yaml shape, WITHOUT the I/O side-effects of PWN::Config.default_env. Prefers PWN::Config.env_template when that refactor is present.\n"}]}
|
|
1059
|
+
{"messages":[{"role":"user","content":"PWN::Migrate.help Usage"},{"role":"assistant","content":"`PWN::Migrate.help`: "}]}
|
|
1060
|
+
{"messages":[{"role":"user","content":"PWN::Migrate.human_size Usage"},{"role":"assistant","content":"`PWN::Migrate.human_size`: "}]}
|
|
1061
|
+
{"messages":[{"role":"user","content":"PWN::Migrate.installed_schema Usage"},{"role":"assistant","content":"`PWN::Migrate.installed_schema`: Supported Method Parameters\n\nschema = PWN::Migrate.installed_schema\n\n→ Integer schema number stamped in ~/.pwn/.schema (0 when absent).\n"}]}
|
|
1062
|
+
{"messages":[{"role":"user","content":"PWN::Migrate.jsonl_verify Usage"},{"role":"assistant","content":"`PWN::Migrate.jsonl_verify`: "}]}
|
|
1063
|
+
{"messages":[{"role":"user","content":"PWN::Migrate.missing_paths Usage"},{"role":"assistant","content":"`PWN::Migrate.missing_paths`: "}]}
|
|
1064
|
+
{"messages":[{"role":"user","content":"PWN::Migrate.needed? Usage"},{"role":"assistant","content":"`PWN::Migrate.needed?`: Supported Method Parameters\n\nbool = PWN::Migrate.needed?\n\nCheap predicate for PWN::Config.refresh_env — schema stamp only, no per-file probes, so it adds ~0 to launch time.\n"}]}
|
|
1065
|
+
{"messages":[{"role":"user","content":"PWN::Migrate.quarantine! Usage"},{"role":"assistant","content":"`PWN::Migrate.quarantine!`: "}]}
|
|
1066
|
+
{"messages":[{"role":"user","content":"PWN::Migrate.run Usage"},{"role":"assistant","content":"`PWN::Migrate.run`: Supported Method Parameters\n\nreport = PWN::Migrate.run(\n\nfix: 'optional - also autofix incompatible files (default: schema-migrations only)',\nbackup: 'optional - take timestamped backup of ~/.pwn first (default true)',\ndry_run: 'optional - report what WOULD happen, write nothing (default false)',\nkey: 'optional - vault key (default: read ~/.pwn/pwn.yaml.decryptor)',\niv: 'optional - vault iv (default: read ~/.pwn/pwn.yaml.decryptor)',\nio: 'optional - IO to write to (default $stdout)'\n\n)\n\nbackup 2. schema migrations installed→current 3. per-file\n\n\nautofix (when fix:true) 4. pwn.yaml key backfill (when fix:true and decryptor available) 5. stamp .schema.\n"}]}
|
|
1067
|
+
{"messages":[{"role":"user","content":"PWN::Migrate.safe_read Usage"},{"role":"assistant","content":"`PWN::Migrate.safe_read`: "}]}
|
|
1068
|
+
{"messages":[{"role":"user","content":"PWN::Migrate.stamp! Usage"},{"role":"assistant","content":"`PWN::Migrate.stamp!`: "}]}
|
|
1069
|
+
{"messages":[{"role":"user","content":"PWN::Migrate.status Usage"},{"role":"assistant","content":"`PWN::Migrate.status`: Supported Method Parameters\n\nrows = PWN::Migrate.status\n\nPer-file compatibility report — the machine-readable half of ‘.check`. Never writes. Never raises (each verifier is rescued).\n"}]}
|
|
1070
|
+
{"messages":[{"role":"user","content":"PWN::Migrate.strip_bad_jsonl! Usage"},{"role":"assistant","content":"`PWN::Migrate.strip_bad_jsonl!`: "}]}
|
|
1071
|
+
{"messages":[{"role":"user","content":"PWN::Migrate.vault_drift Usage"},{"role":"assistant","content":"`PWN::Migrate.vault_drift`: Supported Method Parameters\n\ndrift = PWN::Migrate.vault_drift(\n\nkey: 'optional - vault key', iv: 'optional - vault iv'\n\n)\n\nDeep-diff the user’s decrypted pwn.yaml against the current PWN::Config.env_template. → { missing: [dotted.paths], user: {…} } Returns nil when the decryptor is unavailable (never prompts).\n"}]}
|
|
1072
|
+
{"messages":[{"role":"user","content":"PWN::Migrate.verify_one Usage"},{"role":"assistant","content":"`PWN::Migrate.verify_one`: "}]}
|
|
1050
1073
|
{"messages":[{"role":"user","content":"PWN::Plugins.authors Usage"},{"role":"assistant","content":"`PWN::Plugins.authors`: Author(s)\n\n0day Inc. <support@0dayinc.com>\n"}]}
|
|
1051
1074
|
{"messages":[{"role":"user","content":"PWN::Plugins.help Usage"},{"role":"assistant","content":"`PWN::Plugins.help`: "}]}
|
|
1052
1075
|
{"messages":[{"role":"user","content":"PWN::Plugins::Android.adb_net_connect Usage"},{"role":"assistant","content":"`PWN::Plugins::Android.adb_net_connect`: Supported Method Parameters\n\nPWN::Plugins::Android.adb_net_connect(\n\nadb_path: 'required - path to adb binary',\ntarget: 'required - target host or IP to connect',\nport: 'optional - defaults to tcp 5555'\n\n)\n"}]}
|
|
@@ -2149,6 +2172,7 @@
|
|
|
2149
2172
|
{"messages":[{"role":"user","content":"PWN::Setup.gem_loadable? Usage"},{"role":"assistant","content":"`PWN::Setup.gem_loadable?`: "}]}
|
|
2150
2173
|
{"messages":[{"role":"user","content":"PWN::Setup.help Usage"},{"role":"assistant","content":"`PWN::Setup.help`: "}]}
|
|
2151
2174
|
{"messages":[{"role":"user","content":"PWN::Setup.list_profiles Usage"},{"role":"assistant","content":"`PWN::Setup.list_profiles`: Supported Method Parameters\n\nPWN::Setup.list_profiles(\n\nio: 'optional - IO to write to (default $stdout)'\n\n)\n"}]}
|
|
2175
|
+
{"messages":[{"role":"user","content":"PWN::Setup.migrate Usage"},{"role":"assistant","content":"`PWN::Setup.migrate`: Supported Method Parameters\n\nPWN::Setup.migrate(\n\nfix: 'optional - also autofix incompatible ~/.pwn files (default false)',\ndry_run: 'optional - print what WOULD happen (default false)',\nyes: 'optional - alias for fix:true (CI-friendly)',\nio: 'optional - IO to write to (default $stdout)'\n\n)\n\nDelegate to PWN::Migrate.run — verify every ~/.pwn state file is compatible with THIS pwn release and (optionally) autofix it. A timestamped backup of ~/.pwn is taken first. See ‘PWN::Migrate.help`.\n"}]}
|
|
2152
2176
|
{"messages":[{"role":"user","content":"PWN::Setup.pkg_manager Usage"},{"role":"assistant","content":"`PWN::Setup.pkg_manager`: Supported Method Parameters\n\nPWN::Setup.pkg_manager\n"}]}
|
|
2153
2177
|
{"messages":[{"role":"user","content":"PWN::Setup.which Usage"},{"role":"assistant","content":"`PWN::Setup.which`: "}]}
|
|
2154
2178
|
{"messages":[{"role":"user","content":"PWN::WWW.authors Usage"},{"role":"assistant","content":"`PWN::WWW.authors`: Author(s)\n\n0day Inc. <support@0dayinc.com>\n"}]}
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pwn
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.5.
|
|
4
|
+
version: 0.5.636
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- 0day Inc.
|
|
@@ -2040,6 +2040,7 @@ files:
|
|
|
2040
2040
|
- lib/pwn/ffi/volk.rb
|
|
2041
2041
|
- lib/pwn/memory.rb
|
|
2042
2042
|
- lib/pwn/memory_index.rb
|
|
2043
|
+
- lib/pwn/migrate.rb
|
|
2043
2044
|
- lib/pwn/plugins.rb
|
|
2044
2045
|
- lib/pwn/plugins/android.rb
|
|
2045
2046
|
- lib/pwn/plugins/assembly.rb
|
|
@@ -2489,6 +2490,7 @@ files:
|
|
|
2489
2490
|
- spec/lib/pwn/ffi_spec.rb
|
|
2490
2491
|
- spec/lib/pwn/memory_index_spec.rb
|
|
2491
2492
|
- spec/lib/pwn/memory_spec.rb
|
|
2493
|
+
- spec/lib/pwn/migrate_spec.rb
|
|
2492
2494
|
- spec/lib/pwn/plugins/android_spec.rb
|
|
2493
2495
|
- spec/lib/pwn/plugins/assembly_spec.rb
|
|
2494
2496
|
- spec/lib/pwn/plugins/authentication_helper_spec.rb
|