pwn 0.5.632 → 0.5.634

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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/install-matrix.yml +17 -7
  3. data/.rubocop.yml +1 -0
  4. data/Gemfile +2 -2
  5. data/Rakefile +1 -1
  6. data/bin/pwn_fuzz_net_app_proto +1 -7
  7. data/documentation/Agent-Tool-Registry.md +2 -2
  8. data/documentation/Installation.md +1 -1
  9. data/git_commit.sh +1 -1
  10. data/lib/pwn/ai/agent/learning.rb +13 -16
  11. data/lib/pwn/ai/agent/prompt_builder.rb +7 -3
  12. data/lib/pwn/ai/agent/tools/skills.rb +122 -63
  13. data/lib/pwn/config.rb +245 -30
  14. data/lib/pwn/ffi/stdio.rb +3 -1
  15. data/lib/pwn/plugins/fuzz.rb +9 -7
  16. data/lib/pwn/plugins/github.rb +205 -47
  17. data/lib/pwn/setup.rb +16 -5
  18. data/lib/pwn/version.rb +1 -1
  19. data/packer/provisioners/pwn.sh +2 -1
  20. data/pwn.gemspec +35 -3
  21. data/spec/integration/bin_drivers_spec.rb +73 -0
  22. data/spec/integration/config_spec.rb +68 -0
  23. data/spec/integration/dispatch_spec.rb +106 -0
  24. data/spec/integration/documentation_sync_spec.rb +74 -0
  25. data/spec/integration/extrospection_classify_spec.rb +90 -0
  26. data/spec/integration/help_contract_spec.rb +104 -0
  27. data/spec/integration/persistence_roundtrip_spec.rb +112 -0
  28. data/spec/integration/prompt_builder_spec.rb +68 -0
  29. data/spec/integration/reinforced_feedback_loop_spec.rb +121 -0
  30. data/spec/integration/reports_spec.rb +76 -0
  31. data/spec/integration/result_condition_spec.rb +77 -0
  32. data/spec/integration/sast_functional_spec.rb +80 -0
  33. data/spec/integration/tool_registry_spec.rb +91 -0
  34. data/spec/lib/pwn/ai/agent/tools/skills_spec.rb +88 -1
  35. data/spec/lib/pwn/ffi/adalm_pluto_spec.rb +5 -1
  36. data/spec/spec_helper.rb +2 -0
  37. data/spec/support/sandbox.rb +77 -0
  38. data/third_party/pwn_rdoc.jsonl +16 -5
  39. metadata +31 -18
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5bea724f1a504fa45e1f4eaa786761599a75e08a07b5848327b1dafd61dbe6f2
4
- data.tar.gz: 9a4e693fe7b54d7477896d78272fb5c87a42deb5719730489a0a623eb39230dd
3
+ metadata.gz: 8632bee6e87072a40fbd993834156ad06e1c448918b2922125d592debd7c033d
4
+ data.tar.gz: 399687f4f6ea85666f9e7d8e4c0450998be2949218b45d68b061e8c20c452010
5
5
  SHA512:
6
- metadata.gz: 3998a8a58340cdf1c8b4a9ba9a57a8fe9fb7a749117a606f65b643996ec343606692d5382f71ccd6e119ceac6f995047092bdd87f2e14e8e575b1a306ee29b1f
7
- data.tar.gz: c10128b60319de27d0404c18289b91dd40ca78ca18df522e788c6b2a968e85c8b71c49c2447ef6eee268538787c1b09ba786a303a9fe8eb5198912eb4834ab90
6
+ metadata.gz: 964a576117407e3e5907a302106c2a0f657bedc9a4ebd59167ca360f7f89dbcb3975aec4b6c036e3cc2f138461044f162371f8b2e62d05e5f0ca1c498fa852b4
7
+ data.tar.gz: 8cae2ebdeb246fc190c49aed8b5a787372a482e2eecc3027116eab1bca985b33d9451cd22df4f158f1d4e0e8dc4314f6b1754f4e3b42f68dd8f7c1808b7ea3e6
@@ -57,8 +57,8 @@ jobs:
57
57
  # Base axes → 5 images × 2 profiles = 10 legs.
58
58
  image:
59
59
  - kalilinux/kali-rolling
60
- - debian:bookworm
61
- - ubuntu:24.04
60
+ - debian:trixie
61
+ - ubuntu:latest
62
62
  - fedora:latest
63
63
  - archlinux:latest
64
64
  profile:
@@ -73,10 +73,10 @@ jobs:
73
73
  - image: kalilinux/kali-rolling
74
74
  pm: apt
75
75
  ruby: apt-get update && apt-get install -y ruby-full ruby-dev build-essential git
76
- - image: debian:bookworm
76
+ - image: debian:trixie
77
77
  pm: apt
78
78
  ruby: apt-get update && apt-get install -y ruby-full ruby-dev build-essential git
79
- - image: ubuntu:24.04
79
+ - image: ubuntu:latest
80
80
  pm: apt
81
81
  ruby: apt-get update && apt-get install -y ruby-full ruby-dev build-essential git
82
82
  - image: fedora:latest
@@ -95,8 +95,15 @@ jobs:
95
95
  run: ${{ matrix.ruby }}
96
96
 
97
97
  - name: checkout
98
- uses: actions/checkout@v4
98
+ uses: actions/checkout@v5
99
99
 
100
+
101
+ - name: trust git checkout owned by host UID (container job)
102
+ # actions/checkout marks the repo safe under a temp $HOME that doesn't
103
+ # survive into subsequent steps. Without this, `git ls-files` in
104
+ # pwn.gemspec (and any spec that shells out to git) fails with
105
+ # "detected dubious ownership" and the gem builds with ZERO files.
106
+ run: git config --global --add safe.directory "$PWD"
100
107
  - name: build & install gem from checkout (git-checkout path in doc)
101
108
  run: |
102
109
  gem build pwn.gemspec
@@ -124,7 +131,7 @@ jobs:
124
131
  PWN_EXPECTED_PM: brew
125
132
  PWN_PROFILE: core
126
133
  steps:
127
- - uses: actions/checkout@v4
134
+ - uses: actions/checkout@v5
128
135
 
129
136
  - uses: ruby/setup-ruby@v1
130
137
  with:
@@ -164,8 +171,11 @@ jobs:
164
171
  steps:
165
172
  - run: apt-get update && apt-get install -y ruby-full ruby-dev build-essential git
166
173
 
167
- - uses: actions/checkout@v4
174
+ - uses: actions/checkout@v5
175
+
168
176
 
177
+ - name: trust git checkout owned by host UID (container job)
178
+ run: git config --global --add safe.directory "$PWD"
169
179
  - name: run legacy provisioner (must delegate to `pwn setup`)
170
180
  run: bash packer/provisioners/pwn.sh
171
181
 
data/.rubocop.yml CHANGED
@@ -1,5 +1,6 @@
1
1
  AllCops:
2
2
  UseCache: false
3
+ SuggestExtensions: false
3
4
  NewCops: enable
4
5
  Layout/LineLength:
5
6
  Max: 1620
data/Gemfile CHANGED
@@ -50,7 +50,7 @@ gem 'libusb', '0.7.2'
50
50
  gem 'luhn', '3.0.0'
51
51
  gem 'mail', '2.9.1'
52
52
  gem 'mcp', '0.24.0'
53
- gem 'meshtastic', '0.0.171'
53
+ gem 'meshtastic', '0.0.172'
54
54
  gem 'metasm', '1.0.6'
55
55
  gem 'mongo', '2.24.1'
56
56
  gem 'msfrpc-client', '1.1.2'
@@ -105,4 +105,4 @@ gem 'webrick', '1.9.2'
105
105
  gem 'whois', '6.0.3'
106
106
  gem 'whois-parser', '2.0.0'
107
107
  gem 'wicked_pdf', '2.8.2'
108
- gem 'yard', '0.9.44'
108
+ gem 'yard', '0.9.45'
data/Rakefile CHANGED
@@ -20,5 +20,5 @@ if defined?(RDoc::Task)
20
20
  end
21
21
 
22
22
  default_tasks = %i[spec rubocop]
23
- default_tasks << :rdoc if defined?(RDoc::Task)
23
+ default_tasks << :rerdoc if defined?(RDoc::Task)
24
24
  task default: default_tasks
@@ -75,13 +75,7 @@ pwn_provider = 'ruby-gem'
75
75
  # pwn_provider = ENV.fetch('PWN_PROVIDER') if ENV.keys.select { |s| s == 'PWN_PROVIDER' }.any?
76
76
  pwn_provider = ENV.fetch('PWN_PROVIDER') if ENV.keys.any? { |s| s == 'PWN_PROVIDER' }
77
77
 
78
- if pwn_provider == 'ruby-gem'
79
- require 'pwn'
80
- elsif pwn_provider == 'docker'
81
- require '/opt/pwn/lib/pwn'
82
- else
83
- raise "PWN_PROVIDER env variable is not set to 'ruby-gem' or 'docker'"
84
- end
78
+ raise "PWN_PROVIDER env variable is not set to 'ruby-gem' or 'docker'" unless %w[ruby-gem docker].include?(pwn_provider)
85
79
 
86
80
  dir_path = opts[:dir_path] ||= '.'
87
81
  target = opts[:target]
@@ -13,9 +13,9 @@ toolsets; the JSON-Schema for each tool is what the model actually sees.
13
13
  | `terminal` | `shell` | `Open3.capture3` on the host |
14
14
  | `pwn` | `pwn_eval` | `TOPLEVEL_BINDING.eval` in the live REPL process |
15
15
  | `memory` | `memory_remember` · `memory_recall` · `memory_forget` · `memory_clear` | `PWN::Memory` → `~/.pwn/memory.json` |
16
- | `skills` | `skill_list` · `skill_view` · `skill_create` · `skill_add_reference` · `skill_delete` | `~/.pwn/skills/*.md` |
16
+ | `skills` | `skill_list` · `skill_view` · `skill_create` · `skill_add_reference` · `skill_delete` · `skill_migrate_legacy` | `~/.pwn/skills/<name>/SKILL.md` (agentskills.io; legacy flat `*.md` still read) |
17
17
  | `sessions` | `sessions_list` · `sessions_view` · `sessions_current` · `sessions_delete` · `sessions_stats` | `PWN::Sessions` → `~/.pwn/sessions/` |
18
- | `learning` | `learning_note_outcome` · `learning_reflect` · `learning_distill_skill` · `learning_stats` · `learning_outcomes` · `learning_consolidate` · `learning_reset` · `learning_auto_introspect_toggle` · **`mistakes_list`** · **`mistakes_record`** · **`mistakes_resolve`** · **`mistakes_reset`** | `PWN::AI::Agent::Learning` + `PWN::AI::Agent::Mistakes` → `~/.pwn/learning.jsonl` + `~/.pwn/mistakes.json` |
18
+ | `learning` | `learning_note_outcome` · `learning_reflect` · `learning_distill_skill` · `learning_stats` · `learning_outcomes` · `learning_consolidate` · `learning_reset` · `learning_auto_introspect_toggle` · `learning_purge_noise` · **`mistakes_list`** · **`mistakes_record`** · **`mistakes_resolve`** · **`mistakes_reset`** · **`reward_judge`** · **`reward_prm`** · **`reward_sentinel`** · **`reward_preferences`** · **`reward_export_dpo`** · **`curriculum_practice`** · **`curriculum_train`** · **`curriculum_hindsight`** | `PWN::AI::Agent::Learning` + `Mistakes` + `Reward` + `Curriculum` → `~/.pwn/learning.jsonl` + `~/.pwn/mistakes.json` + `~/.pwn/preferences.jsonl` + `~/.pwn/curriculum/` |
19
19
  | `metrics` | `metrics_summary` · `metrics_reset` | `PWN::AI::Agent::Metrics` → `~/.pwn/metrics.json` |
20
20
  | `extrospection` | `extro_snapshot` · `extro_drift` · `extro_observe` · `extro_observations` · `extro_intel` · **`extro_watch`** · **`extro_verify`** · **`extro_rf_tune`** · **`extro_osint`** · **`extro_serial`** · **`extro_telecomm`** · **`extro_packet`** · **`extro_vision`** · **`extro_voice`** · `extro_correlate` · `extro_stats` · `extro_reset` · `extro_auto_toggle` | `PWN::AI::Agent::Extrospection` (+ Serial/Packet/OCR/Voice/BareSIP/TransparentBrowser) → `~/.pwn/extrospection.json` |
21
21
  | `cron` | `cron_list` · `cron_create` · `cron_run` · `cron_enable` · `cron_disable` · `cron_remove` | `PWN::Cron` → `~/.pwn/cron/jobs.yml` |
@@ -17,7 +17,7 @@ needed to unlock the ones you want. No `/opt/pwn` checkout, no `rvmsudo`
17
17
  chain, no bash provisioners required.
18
18
 
19
19
  Tested on **Kali / Debian / Ubuntu**, **Fedora**, **Arch**, and **macOS**
20
- (Homebrew or MacPorts). Ruby ≥ 3.4 (RVM, rbenv, asdf, or system Ruby).
20
+ (Homebrew or MacPorts). Ruby ≥ 3.3 (RVM, rbenv, asdf, or system Ruby).
21
21
 
22
22
  ---
23
23
 
data/git_commit.sh CHANGED
@@ -27,7 +27,7 @@ if (( $# == 3 )); then
27
27
  if [[ $? -ne 0 ]]; then
28
28
  echo 'ERROR: pwn_autoinc_version failed! Reinstalling pwn gemset...'
29
29
  rvmsudo ./reinstall_pwn_gemset.sh
30
- rvmsudo rake
30
+ rake
31
31
  rvmsudo rake install
32
32
  if [[ $? -ne 0 ]]; then
33
33
  echo 'ERROR: Attempt to reinstall pwn gemset failed! Please investigate and fix before trying again.'
@@ -225,26 +225,23 @@ module PWN
225
225
  # )
226
226
 
227
227
  public_class_method def self.distill_skill(opts = {})
228
- name = opts[:name].to_s.gsub(/[^a-z0-9_-]/i, '_')
229
- raise 'ERROR: name is required' if name.empty?
228
+ raise 'ERROR: name is required' if opts[:name].to_s.strip.empty?
230
229
 
231
230
  body = opts[:content].to_s
232
- body = build_skill_from_session(session_id: opts[:session_id], name: name) if body.strip.empty? && opts[:session_id]
231
+ body = build_skill_from_session(session_id: opts[:session_id], name: opts[:name]) if body.strip.empty? && opts[:session_id]
233
232
  raise 'ERROR: content or session_id is required' if body.strip.empty?
234
233
 
235
- refs = Array(opts[:references]).map(&:to_s).map(&:strip).reject(&:empty?).uniq
236
- unless refs.empty?
237
- body = "---\nreferences:\n#{refs.map { |r| " - #{r}" }.join("\n")}\n---\n#{body}" unless body.start_with?("---\n")
238
- body = "#{body.rstrip}\n\n## References\n#{refs.map { |r| "- #{r}" }.join("\n")}\n" unless body =~ /^\#{1,3}\s*References\s*$/i
239
- end
240
-
241
- dir = skills_dir
242
- FileUtils.mkdir_p(dir)
243
- path = File.join(dir, "#{name}.md")
244
- File.write(path, body)
245
- PWN::Config.load_skills(pwn_skills_path: dir) if defined?(PWN::Config) && PWN::Config.respond_to?(:load_skills)
246
- note_outcome(task: "distill_skill:#{name}", success: true, details: "Saved #{path}", tags: %w[skill auto])
247
- { saved: true, name: name, path: path, bytes: body.bytesize, references: refs }
234
+ root = skills_dir
235
+ out = PWN::Config.write_skill(
236
+ name: opts[:name],
237
+ description: opts[:description],
238
+ content: body,
239
+ references: opts[:references],
240
+ pwn_skills_path: root
241
+ )
242
+ PWN::Config.load_skills(pwn_skills_path: root) if PWN::Config.respond_to?(:load_skills)
243
+ note_outcome(task: "distill_skill:#{out[:name]}", success: true, details: "Saved #{out[:path]}", tags: %w[skill auto])
244
+ out.merge(saved: true)
248
245
  end
249
246
 
250
247
  # Supported Method Parameters::
@@ -117,11 +117,15 @@ module PWN
117
117
  return '' unless defined?(PWN::Skills) && PWN::Skills.is_a?(Hash) && !PWN::Skills.empty?
118
118
 
119
119
  lines = PWN::Skills.map do |name, meta|
120
- first = meta[:content].to_s.lines.reject { |l| l.strip.empty? || l.start_with?('---') }.first.to_s.strip
121
- first = first[0, 100]
120
+ desc = meta[:description].to_s.strip
121
+ if desc.empty?
122
+ # legacy / stubbed entry without a parsed description — fall back
123
+ desc = meta[:content].to_s.lines.reject { |l| l.strip.empty? || l.start_with?('---') }.first.to_s.strip
124
+ end
125
+ desc = desc[0, 100]
122
126
  rc = Array(meta[:references]).length
123
127
  ref_tag = rc.positive? ? " [#{rc} refs]" : ''
124
- " - #{name}: #{first}#{ref_tag}"
128
+ " - #{name}: #{desc}#{ref_tag}"
125
129
  end
126
130
  "SKILLS (call skill_view to read full body)\n#{lines.join("\n")}\n\n"
127
131
  rescue StandardError
@@ -3,14 +3,23 @@
3
3
  require 'fileutils'
4
4
  require 'pwn/ai/agent/registry'
5
5
 
6
- # Thin wrappers around the PWN::Skills store (~/.pwn/skills/*.md) so the
7
- # model can list/read/write reusable procedures. PromptBuilder injects
8
- # the index (name + first line); skill_view loads the full body on demand.
6
+ # Thin wrappers around the PWN::Skills store so the model can list / read /
7
+ # write reusable procedures. PromptBuilder injects the index (name +
8
+ # description); skill_view loads the full body on demand — the two-tier
9
+ # progressive-disclosure pattern from https://agentskills.io/specification.
10
+ #
11
+ # On disk (spec-conformant, written by PWN::Config.write_skill):
12
+ # ~/.pwn/skills/<name>/SKILL.md ← YAML frontmatter (name, description, …)
13
+ # ~/.pwn/skills/<name>/scripts/ ← optional executable helpers
14
+ # ~/.pwn/skills/<name>/references/ ← optional supporting docs
15
+ #
16
+ # Legacy flat ~/.pwn/skills/<name>.md files are still LOADED (format:
17
+ # :legacy) so existing installs keep working; new writes always use the
18
+ # directory layout. Run PWN::Config.migrate_legacy_skills to convert.
9
19
  #
10
20
  # Each skill may carry a :references Array (URLs, CWE/CVE/ATT&CK ids,
11
- # NIST 800-53 controls, docs) parsed from YAML front-matter or a
12
- # "## References" markdown section. References let the agent cite
13
- # authoritative sources when applying a skill and mirror the
21
+ # NIST 800-53 controls, docs) parsed from frontmatter `metadata.references`
22
+ # and/or a "## References" markdown section mirroring the
14
23
  # PWN::SAST::*.security_references convention.
15
24
 
16
25
  PWN::AI::Agent::Registry.register(
@@ -18,7 +27,7 @@ PWN::AI::Agent::Registry.register(
18
27
  toolset: 'skills',
19
28
  schema: {
20
29
  name: 'skill_list',
21
- description: 'List available pwn-ai skills (name + first line + reference count).',
30
+ description: 'List available pwn-ai skills (name + description + reference count + format).',
22
31
  parameters: { type: 'object', properties: {}, required: [] }
23
32
  },
24
33
  check: -> { defined?(PWN::Skills) && PWN::Skills.is_a?(Hash) },
@@ -27,7 +36,8 @@ PWN::AI::Agent::Registry.register(
27
36
  {
28
37
  name: name,
29
38
  type: meta[:type],
30
- summary: meta[:content].to_s.lines.first.to_s.strip[0, 120],
39
+ format: meta[:format] || :legacy,
40
+ description: (meta[:description] || meta[:content].to_s.lines.first.to_s.strip)[0, 200],
31
41
  references: Array(meta[:references]).length
32
42
  }
33
43
  end
@@ -39,7 +49,7 @@ PWN::AI::Agent::Registry.register(
39
49
  toolset: 'skills',
40
50
  schema: {
41
51
  name: 'skill_view',
42
- description: 'Read the full content of a named skill, including its references.',
52
+ description: 'Read the full content of a named skill, including its frontmatter and references.',
43
53
  parameters: {
44
54
  type: 'object',
45
55
  properties: { name: { type: 'string' } },
@@ -49,13 +59,17 @@ PWN::AI::Agent::Registry.register(
49
59
  check: -> { defined?(PWN::Skills) && PWN::Skills.is_a?(Hash) },
50
60
  handler: lambda { |args|
51
61
  key = args[:name].to_s.to_sym
52
- meta = PWN::Skills[key]
62
+ meta = PWN::Skills[key] || PWN::Skills[PWN::Config.sanitize_skill_name(name: key).to_sym]
53
63
  raise ArgumentError, "no such skill: #{key}" unless meta
54
64
 
55
65
  {
56
66
  name: key,
57
67
  type: meta[:type],
68
+ format: meta[:format] || :legacy,
58
69
  path: meta[:path],
70
+ dir: meta[:dir],
71
+ description: meta[:description],
72
+ frontmatter: meta[:frontmatter] || {},
59
73
  references: Array(meta[:references]),
60
74
  content: meta[:content]
61
75
  }
@@ -67,49 +81,46 @@ PWN::AI::Agent::Registry.register(
67
81
  toolset: 'skills',
68
82
  schema: {
69
83
  name: 'skill_create',
70
- description: 'Save a new reusable skill (markdown procedure) to ~/.pwn/skills/. ' \
71
- 'It will be listed in every future pwn-ai system prompt. Optionally ' \
72
- 'attach references (URLs, CWE/CVE ids, MITRE ATT&CK, NIST 800-53, docs).',
84
+ description: 'Save a new reusable skill to ~/.pwn/skills/<name>/SKILL.md ' \
85
+ '(agentskills.io format: required name + description YAML ' \
86
+ 'frontmatter). Appears in every future pwn-ai system prompt. ' \
87
+ 'Optionally attach references (URLs, CWE/CVE ids, MITRE ' \
88
+ 'ATT&CK, NIST 800-53, docs) and an allowed-tools allowlist.',
73
89
  parameters: {
74
90
  type: 'object',
75
91
  properties: {
76
- name: { type: 'string', description: 'lowercase, hyphens/underscores only' },
77
- content: { type: 'string', description: 'Full markdown body of the skill.' },
92
+ name: { type: 'string', description: 'Free-form; sanitised to [a-z0-9-]{1,64} per spec.' },
93
+ description: { type: 'string', description: '1..1024 char summary shown in the SKILLS index. Derived from body when omitted.' },
94
+ content: { type: 'string', description: 'Full markdown body of the skill (WITHOUT frontmatter — it is generated).' },
78
95
  references: {
79
96
  type: 'array',
80
97
  items: { type: 'string' },
81
98
  description: 'Optional list of reference URLs / identifiers (CWE-79, T1059, ' \
82
- 'https://..., NIST SI-3, etc.). Written as YAML front-matter and ' \
83
- 'a "## References" section.'
84
- }
99
+ 'https://..., NIST SI-3, etc.). Written under frontmatter ' \
100
+ 'metadata.references and as a "## References" section.'
101
+ },
102
+ license: { type: 'string', description: 'Optional SPDX identifier or free text.' },
103
+ allowed_tools: { type: 'array', items: { type: 'string' }, description: 'Optional toolset allowlist for this skill.' },
104
+ metadata: { type: 'object', description: 'Optional arbitrary metadata hash.' }
85
105
  },
86
106
  required: %w[name content]
87
107
  }
88
108
  },
89
- check: -> { defined?(PWN::Config) && PWN::Config.respond_to?(:pwn_skills_path) },
109
+ check: -> { defined?(PWN::Config) && PWN::Config.respond_to?(:write_skill) },
90
110
  handler: lambda { |args|
91
- name = args[:name].to_s.gsub(/[^a-z0-9_-]/i, '_')
92
- raise ArgumentError, 'name is required' if name.empty?
93
-
94
- body = args[:content].to_s
95
- refs = Array(args[:references]).map(&:to_s).map(&:strip).reject(&:empty?).uniq
96
-
97
- unless refs.empty?
98
- # YAML front-matter (only if body doesn't already have one)
99
- unless body.start_with?("---\n")
100
- fm = "---\nreferences:\n#{refs.map { |r| " - #{r}" }.join("\n")}\n---\n"
101
- body = fm + body
102
- end
103
- # ## References section (only if not already present)
104
- body = "#{body.rstrip}\n\n## References\n#{refs.map { |r| "- #{r}" }.join("\n")}\n" unless body =~ /^\#{1,3}\s*References\s*$/i
105
- end
106
-
107
- dir = PWN::Config.pwn_skills_path
108
- FileUtils.mkdir_p(dir)
109
- path = File.join(dir, "#{name}.md")
110
- File.write(path, body)
111
- PWN::Config.load_skills(pwn_skills_path: dir) # refresh PWN::Skills const
112
- { saved: true, path: path, references: refs, total: PWN::Skills.keys.length }
111
+ root = PWN::Config.pwn_skills_path
112
+ out = PWN::Config.write_skill(
113
+ name: args[:name],
114
+ description: args[:description],
115
+ content: args[:content],
116
+ references: args[:references],
117
+ license: args[:license],
118
+ allowed_tools: args[:allowed_tools],
119
+ metadata: args[:metadata],
120
+ pwn_skills_path: root
121
+ )
122
+ PWN::Config.load_skills(pwn_skills_path: root)
123
+ out.merge(saved: true, total: PWN::Skills.keys.length)
113
124
  }
114
125
  )
115
126
 
@@ -119,7 +130,8 @@ PWN::AI::Agent::Registry.register(
119
130
  schema: {
120
131
  name: 'skill_add_reference',
121
132
  description: 'Append one or more references (URL, CWE/CVE id, MITRE ATT&CK ' \
122
- 'technique, NIST control, doc link) to an existing skill.',
133
+ 'technique, NIST control, doc link) to an existing skill. ' \
134
+ 'Rewrites frontmatter metadata.references + ## References section.',
123
135
  parameters: {
124
136
  type: 'object',
125
137
  properties: {
@@ -132,31 +144,50 @@ PWN::AI::Agent::Registry.register(
132
144
  check: -> { defined?(PWN::Skills) && PWN::Skills.is_a?(Hash) },
133
145
  handler: lambda { |args|
134
146
  key = args[:name].to_s.to_sym
135
- meta = PWN::Skills[key]
147
+ meta = PWN::Skills[key] || PWN::Skills[PWN::Config.sanitize_skill_name(name: key).to_sym]
136
148
  raise ArgumentError, "no such skill: #{key}" unless meta
137
149
 
138
150
  new_refs = Array(args[:references]).map(&:to_s).map(&:strip).reject(&:empty?)
139
151
  raise ArgumentError, 'references must be a non-empty array' if new_refs.empty?
140
152
 
141
- body = File.read(meta[:path])
153
+ root = PWN::Config.pwn_skills_path
142
154
  existing = Array(meta[:references])
143
- add = (new_refs - existing)
144
-
145
- if body =~ /^(\#{1,3}\s*References\s*)$/i
146
- # Append bullets right after the References heading block (end of file safest)
147
- body = "#{body.rstrip}\n#{add.map { |r| "- #{r}" }.join("\n")}\n" unless add.empty?
155
+ combined = (existing + new_refs).uniq
156
+ added = combined - existing
157
+
158
+ if meta[:format] == :agentskills
159
+ # Rewrite via the canonical writer so frontmatter stays valid.
160
+ body = PWN::Config.parse_skill_frontmatter(content: meta[:content])[:body]
161
+ .to_s.sub(/^\#{1,3}\s*References\s*$.*\z/mi, '').rstrip
162
+ PWN::Config.write_skill(
163
+ name: key.to_s,
164
+ description: meta[:description],
165
+ content: body,
166
+ references: combined,
167
+ license: (meta[:frontmatter] || {})['license'],
168
+ allowed_tools: meta[:allowed_tools],
169
+ metadata: (meta[:frontmatter] || {})['metadata'],
170
+ pwn_skills_path: root
171
+ )
148
172
  else
149
- body = "#{body.rstrip}\n\n## References\n#{(existing + add).uniq.map { |r| "- #{r}" }.join("\n")}\n"
173
+ # Legacy flat file — append to (or create) the ## References section in place.
174
+ body = File.read(meta[:path])
175
+ body = if body =~ /^(\#{1,3}\s*References\s*)$/i
176
+ "#{body.rstrip}\n#{added.map { |r| "- #{r}" }.join("\n")}\n"
177
+ else
178
+ "#{body.rstrip}\n\n## References\n#{combined.map { |r| "- #{r}" }.join("\n")}\n"
179
+ end
180
+ File.write(meta[:path], body)
150
181
  end
151
182
 
152
- File.write(meta[:path], body)
153
- PWN::Config.load_skills(pwn_skills_path: File.dirname(meta[:path]))
183
+ PWN::Config.load_skills(pwn_skills_path: root)
184
+ reloaded = PWN::Skills[key] || PWN::Skills[PWN::Config.sanitize_skill_name(name: key).to_sym]
154
185
  {
155
186
  saved: true,
156
187
  name: key,
157
- path: meta[:path],
158
- added: add,
159
- references: Array(PWN::Skills[key][:references])
188
+ path: reloaded[:path],
189
+ added: added,
190
+ references: Array(reloaded[:references])
160
191
  }
161
192
  }
162
193
  )
@@ -166,13 +197,15 @@ PWN::AI::Agent::Registry.register(
166
197
  toolset: 'skills',
167
198
  schema: {
168
199
  name: 'skill_delete',
169
- description: 'Delete a skill from ~/.pwn/skills/ by name. Use to prune ' \
170
- 'low-quality auto-distilled skills so they stop appearing ' \
171
- 'in every future system prompt. Irreversible.',
200
+ description: 'Delete a skill from ~/.pwn/skills/ by name. Removes the ' \
201
+ 'entire <name>/ directory (agentskills format) or the flat ' \
202
+ 'file (legacy). Use to prune low-quality auto-distilled ' \
203
+ 'skills so they stop appearing in every future system prompt. ' \
204
+ 'Irreversible.',
172
205
  parameters: {
173
206
  type: 'object',
174
207
  properties: {
175
- name: { type: 'string', description: 'Existing skill name (basename without extension).' }
208
+ name: { type: 'string', description: 'Existing skill name.' }
176
209
  },
177
210
  required: %w[name]
178
211
  }
@@ -180,13 +213,39 @@ PWN::AI::Agent::Registry.register(
180
213
  check: -> { defined?(PWN::Skills) && PWN::Skills.is_a?(Hash) },
181
214
  handler: lambda { |args|
182
215
  key = args[:name].to_s.to_sym
183
- meta = PWN::Skills[key]
216
+ meta = PWN::Skills[key] || PWN::Skills[PWN::Config.sanitize_skill_name(name: key).to_sym]
184
217
  raise ArgumentError, "no such skill: #{key}" unless meta
185
218
 
219
+ root = PWN::Config.pwn_skills_path
186
220
  path = meta[:path]
187
- dir = File.dirname(path)
188
- FileUtils.rm_f(path)
189
- PWN::Config.load_skills(pwn_skills_path: dir)
221
+ if meta[:format] == :agentskills && meta[:dir] && File.basename(File.dirname(path)) == File.basename(meta[:dir])
222
+ FileUtils.rm_rf(meta[:dir])
223
+ else
224
+ FileUtils.rm_f(path)
225
+ end
226
+ PWN::Config.load_skills(pwn_skills_path: root)
190
227
  { deleted: true, name: key, path: path, remaining: PWN::Skills.keys.length }
191
228
  }
192
229
  )
230
+
231
+ PWN::AI::Agent::Registry.register(
232
+ name: 'skill_migrate_legacy',
233
+ toolset: 'skills',
234
+ schema: {
235
+ name: 'skill_migrate_legacy',
236
+ description: 'One-shot: convert every legacy flat ~/.pwn/skills/*.md into ' \
237
+ 'a spec-conformant <name>/SKILL.md with backfilled name + ' \
238
+ 'description frontmatter. Idempotent; safe to re-run.',
239
+ parameters: {
240
+ type: 'object',
241
+ properties: {
242
+ delete_legacy: { type: 'boolean', default: true, description: 'Remove the flat file after migration.' }
243
+ },
244
+ required: []
245
+ }
246
+ },
247
+ check: -> { defined?(PWN::Config) && PWN::Config.respond_to?(:migrate_legacy_skills) },
248
+ handler: lambda { |args|
249
+ PWN::Config.migrate_legacy_skills(delete_legacy: args.fetch(:delete_legacy, true))
250
+ }
251
+ )