pwn 0.5.632 → 0.5.635

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/install-matrix.yml +24 -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/plugins/repl.rb +12 -1
  18. data/lib/pwn/setup.rb +35 -5
  19. data/lib/pwn/version.rb +1 -1
  20. data/packer/provisioners/pwn.sh +2 -1
  21. data/pwn.gemspec +36 -3
  22. data/spec/integration/bin_drivers_spec.rb +73 -0
  23. data/spec/integration/config_spec.rb +68 -0
  24. data/spec/integration/dispatch_spec.rb +106 -0
  25. data/spec/integration/documentation_sync_spec.rb +74 -0
  26. data/spec/integration/extrospection_classify_spec.rb +90 -0
  27. data/spec/integration/help_contract_spec.rb +104 -0
  28. data/spec/integration/persistence_roundtrip_spec.rb +112 -0
  29. data/spec/integration/prompt_builder_spec.rb +68 -0
  30. data/spec/integration/reinforced_feedback_loop_spec.rb +121 -0
  31. data/spec/integration/reports_spec.rb +76 -0
  32. data/spec/integration/result_condition_spec.rb +77 -0
  33. data/spec/integration/sast_functional_spec.rb +80 -0
  34. data/spec/integration/tool_registry_spec.rb +91 -0
  35. data/spec/lib/pwn/ai/agent/tools/skills_spec.rb +88 -1
  36. data/spec/lib/pwn/ffi/adalm_pluto_spec.rb +5 -1
  37. data/spec/spec_helper.rb +2 -0
  38. data/spec/support/sandbox.rb +77 -0
  39. data/third_party/pwn_rdoc.jsonl +16 -5
  40. metadata +32 -19
data/lib/pwn/config.rb CHANGED
@@ -397,6 +397,30 @@ module PWN
397
397
  raise e
398
398
  end
399
399
 
400
+ # ──────────────────────────────────────────────────────────────────────
401
+ # SKILLS (agentskills.io/specification conformant, with legacy shim)
402
+ # ──────────────────────────────────────────────────────────────────────
403
+ #
404
+ # On-disk layout (spec):
405
+ # ~/.pwn/skills/<name>/SKILL.md ← required entrypoint, YAML frontmatter
406
+ # ~/.pwn/skills/<name>/scripts/ ← optional executables (was flat *.rb)
407
+ # ~/.pwn/skills/<name>/references/ ← optional supporting docs
408
+ # ~/.pwn/skills/<name>/assets/ ← optional binary assets
409
+ #
410
+ # Legacy shim (read-only, still loaded so nothing breaks on upgrade):
411
+ # ~/.pwn/skills/<name>.{md,txt,rb,skill,yml,yaml}
412
+ #
413
+ # Frontmatter (SKILL.md, `---` YAML block at top of file):
414
+ # name: REQUIRED [a-z0-9-]{1,64}, must equal parent dir name
415
+ # description: REQUIRED 1..1024 chars
416
+ # license: optional
417
+ # metadata: optional Hash (pwn stores references here too)
418
+ # allowed-tools: optional Array of toolset names
419
+ # ──────────────────────────────────────────────────────────────────────
420
+
421
+ SKILL_ENTRY = 'SKILL.md'
422
+ SKILL_NAME_RE = /\A[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?\z/
423
+
400
424
  # Supported Method Parameters::
401
425
  # pwn_skills_path = PWN::Config.pwn_skills_path(
402
426
  # pwn_env_path: 'optional - Path to pwn.yaml file. Defaults to ~/.pwn/pwn.yaml'
@@ -406,33 +430,65 @@ module PWN
406
430
  File.join(File.dirname(pwn_env_path), 'skills')
407
431
  end
408
432
 
433
+ # Supported Method Parameters::
434
+ # name = PWN::Config.sanitize_skill_name(name: 'My Cool Skill!')
435
+ #
436
+ # Coerce to an agentskills.io-valid identifier:
437
+ # downcase → non [a-z0-9] → '-' → squeeze '-' → strip edge '-' → cap 64.
438
+ # Raises ArgumentError when the result is empty.
439
+ public_class_method def self.sanitize_skill_name(opts = {})
440
+ n = opts[:name].to_s.downcase
441
+ .gsub(/[^a-z0-9-]+/, '-')
442
+ .gsub(/-{2,}/, '-')
443
+ .gsub(/\A-+|-+\z/, '')[0, 64]
444
+ .to_s
445
+ .gsub(/-+\z/, '') # re-strip in case truncation left a trailing '-'
446
+ raise ArgumentError, "skill name #{opts[:name].inspect} sanitises to empty" if n.empty?
447
+ raise ArgumentError, "skill name #{n.inspect} !~ #{SKILL_NAME_RE.inspect}" unless n.match?(SKILL_NAME_RE)
448
+
449
+ n
450
+ end
451
+
452
+ # Supported Method Parameters::
453
+ # fm = PWN::Config.parse_skill_frontmatter(content: '...')
454
+ #
455
+ # → { frontmatter: Hash(String keys), body: String }
456
+ # Missing / malformed frontmatter returns { frontmatter: {}, body: content }.
457
+ public_class_method def self.parse_skill_frontmatter(opts = {})
458
+ content = opts[:content].to_s
459
+ return { frontmatter: {}, body: content } unless content.start_with?("---\n")
460
+
461
+ fm_end = content.index(/^---\s*$/, 4)
462
+ return { frontmatter: {}, body: content } unless fm_end
463
+
464
+ require 'yaml'
465
+ raw = content[4...fm_end]
466
+ fm = YAML.safe_load(raw, permitted_classes: [Symbol, Date, Time], aliases: true) || {}
467
+ fm = {} unless fm.is_a?(Hash)
468
+ body = content[fm_end..].to_s.sub(/\A---\s*\n?/, '')
469
+ { frontmatter: fm, body: body }
470
+ rescue StandardError
471
+ { frontmatter: {}, body: content }
472
+ end
473
+
409
474
  # Supported Method Parameters::
410
475
  # refs = PWN::Config.parse_skill_references(content: '...')
411
476
  #
412
477
  # Extracts an Array of reference strings (URLs, CWE/CVE/ATT&CK ids, etc.)
413
- # from a skill body. Supports two formats:
414
- # 1) YAML front-matter block: ---\nreferences:\n - https://...\n---\n
415
- # 2) Markdown section: ## References\n- https://...\n
478
+ # from a skill body. Supports three sources, merged & uniq'd:
479
+ # 1) frontmatter `references:` (legacy pwn)
480
+ # 2) frontmatter `metadata: { references: [...] }` (spec-conformant slot)
481
+ # 3) markdown `## References` bullet section
416
482
  public_class_method def self.parse_skill_references(opts = {})
417
483
  content = opts[:content].to_s
418
- refs = []
484
+ parsed = parse_skill_frontmatter(content: content)
485
+ fm = parsed[:frontmatter]
486
+ refs = []
419
487
 
420
- # YAML front-matter (--- ... ---) at top of file
421
- if content.start_with?("---\n")
422
- fm_end = content.index("\n---", 4)
423
- if fm_end
424
- begin
425
- require 'yaml'
426
- fm = YAML.safe_load(content[4..fm_end], permitted_classes: [], aliases: false) || {}
427
- r = fm['references'] || fm[:references]
428
- refs.concat(Array(r).map(&:to_s)) if r
429
- rescue StandardError
430
- # ignore malformed front-matter
431
- end
432
- end
433
- end
488
+ refs.concat(Array(fm['references'] || fm[:references]).map(&:to_s))
489
+ md = fm['metadata'] || fm[:metadata]
490
+ refs.concat(Array(md['references'] || md[:references]).map(&:to_s)) if md.is_a?(Hash)
434
491
 
435
- # Markdown "## References" section (bullets or bare lines until next heading / EOF)
436
492
  if content =~ /^\s*\#{1,3}\s*References\s*$/i
437
493
  in_section = false
438
494
  content.each_line do |line|
@@ -441,7 +497,7 @@ module PWN
441
497
  next
442
498
  end
443
499
  next unless in_section
444
- break if line =~ /^\s*\#{1,3}\s+\S/ # next heading
500
+ break if line =~ /^\s*\#{1,3}\s+\S/
445
501
 
446
502
  l = line.strip.sub(/^[-*]\s*/, '')
447
503
  refs << l unless l.empty?
@@ -453,36 +509,180 @@ module PWN
453
509
  []
454
510
  end
455
511
 
512
+ # Supported Method Parameters::
513
+ # out = PWN::Config.write_skill(
514
+ # name: 'required - free-form; sanitised to [a-z0-9-]',
515
+ # content: 'required - markdown body (WITHOUT frontmatter)',
516
+ # description: 'optional - 1..1024 chars; derived from body when omitted',
517
+ # references: 'optional - Array of URLs / CWE / CVE / ATT&CK / NIST ids',
518
+ # license: 'optional - SPDX id or free text',
519
+ # metadata: 'optional - Hash of arbitrary metadata',
520
+ # allowed_tools: 'optional - Array of toolset names',
521
+ # pwn_skills_path: 'optional - override skills root'
522
+ # )
523
+ #
524
+ # The single agentskills.io-conformant writer used by skill_create,
525
+ # learning_distill_skill and migrate_legacy_skills. Always writes
526
+ # <root>/<name>/SKILL.md with required name+description frontmatter.
527
+ public_class_method def self.write_skill(opts = {})
528
+ root = opts[:pwn_skills_path] || pwn_skills_path
529
+ name = sanitize_skill_name(name: opts[:name])
530
+ body = opts[:content].to_s
531
+ raise ArgumentError, 'content is required' if body.strip.empty?
532
+
533
+ # If caller handed us a body that already has frontmatter, strip &
534
+ # merge it so we never emit doubled `---` blocks.
535
+ parsed = parse_skill_frontmatter(content: body)
536
+ body = parsed[:body].to_s.sub(/\A\n+/, '')
537
+ merged = parsed[:frontmatter]
538
+
539
+ desc = (opts[:description] || merged['description'] || merged[:description]).to_s.strip
540
+ if desc.empty?
541
+ first = body.lines.reject { |l| l.strip.empty? || l.strip.start_with?('#') }.first.to_s.strip
542
+ first = body.lines.first.to_s.strip.sub(/^#+\s*/, '') if first.empty?
543
+ desc = first[0, 1024]
544
+ end
545
+ desc = desc[0, 1024]
546
+ raise ArgumentError, 'description could not be derived (empty body?)' if desc.empty?
547
+
548
+ refs = (Array(opts[:references]) + Array(merged['references']) + Array(merged[:references]))
549
+ .map(&:to_s).map(&:strip).reject(&:empty?).uniq
550
+ meta = merged['metadata'] || merged[:metadata] || {}
551
+ meta = {} unless meta.is_a?(Hash)
552
+ meta = meta.merge(opts[:metadata]) if opts[:metadata].is_a?(Hash)
553
+ meta['references'] = refs unless refs.empty?
554
+
555
+ fm = { 'name' => name, 'description' => desc }
556
+ fm['license'] = opts[:license].to_s if opts[:license]
557
+ fm['allowed-tools'] = Array(opts[:allowed_tools]).map(&:to_s) if opts[:allowed_tools]
558
+ fm['metadata'] = meta unless meta.empty?
559
+
560
+ require 'yaml'
561
+ frontmatter = YAML.dump(fm).sub(/\A---\n/, '') # YAML.dump already emits leading ---
562
+ out = "---\n#{frontmatter}---\n\n#{body.rstrip}\n"
563
+ out << "\n## References\n#{refs.map { |r| "- #{r}" }.join("\n")}\n" if refs.any? && body !~ /^\#{1,3}\s*References\s*$/i
564
+
565
+ dir = File.join(root, name)
566
+ path = File.join(dir, SKILL_ENTRY)
567
+ FileUtils.mkdir_p(dir)
568
+ File.write(path, out)
569
+
570
+ { name: name, dir: dir, path: path, bytes: out.bytesize, description: desc, references: refs, format: :agentskills }
571
+ end
572
+
573
+ # Supported Method Parameters::
574
+ # report = PWN::Config.migrate_legacy_skills(
575
+ # pwn_skills_path: 'optional - override skills root',
576
+ # delete_legacy: 'optional - remove flat file after migration (default true)'
577
+ # )
578
+ #
579
+ # One-shot converter: every flat ~/.pwn/skills/*.md (etc.) becomes a
580
+ # spec-conformant <name>/SKILL.md with backfilled frontmatter. Idempotent.
581
+ public_class_method def self.migrate_legacy_skills(opts = {})
582
+ root = opts[:pwn_skills_path] || pwn_skills_path
583
+ del = opts.fetch(:delete_legacy, true)
584
+ migrated = []
585
+ Dir.glob(File.join(root, '*.{rb,md,txt,skill,yml,yaml}')).each do |legacy|
586
+ content = File.read(legacy)
587
+ base = File.basename(legacy, '.*')
588
+ out = write_skill(name: base, content: content, pwn_skills_path: root)
589
+ if File.extname(legacy) == '.rb'
590
+ FileUtils.mkdir_p(File.join(out[:dir], 'scripts'))
591
+ FileUtils.cp(legacy, File.join(out[:dir], 'scripts', File.basename(legacy)))
592
+ end
593
+ FileUtils.rm_f(legacy) if del
594
+ migrated << { from: legacy, to: out[:path] }
595
+ rescue StandardError => e
596
+ migrated << { from: legacy, error: e.message }
597
+ end
598
+ load_skills(pwn_skills_path: root)
599
+ { migrated: migrated.length, details: migrated }
600
+ end
601
+
456
602
  # Supported Method Parameters::
457
603
  # skills = PWN::Config.load_skills(
458
604
  # pwn_skills_path: 'optional - Path to skills folder. Defaults to ~/.pwn/skills'
459
605
  # )
460
606
  #
461
- # Loads instruction-based skills (.md, .txt, .skill, .yaml) and executable Ruby skills (.rb)
462
- # into PWN::Skills constant (hash of basename => {type, path, content, loaded?}).
463
- # The pwn-ai command (REPL driver) loads and is aware of this folder to expand
464
- # autonomous agent capabilities (skill documents loaded for task execution).
607
+ # Loads skills into the PWN::Skills constant. Two on-disk shapes are
608
+ # accepted so upgrades are seamless:
609
+ #
610
+ # agentskills.io → <root>/<name>/SKILL.md (preferred; written by write_skill)
611
+ # legacy flat → <root>/<name>.{md,txt,rb,skill,yml,yaml}
612
+ #
613
+ # Each entry: { type:, format:, path:, dir:, content:, description:,
614
+ # references:, frontmatter:, loaded:?, error:? }
465
615
  public_class_method def self.load_skills(opts = {})
466
- pwn_skills_path = opts[:pwn_skills_path] || PWN::Env[:pwn_skills_path] || pwn_skills_path
616
+ pwn_skills_path = opts[:pwn_skills_path] || (PWN.const_defined?(:Env) && PWN::Env.is_a?(Hash) && PWN::Env[:pwn_skills_path]) || self.pwn_skills_path
467
617
  FileUtils.mkdir_p(pwn_skills_path) if pwn_skills_path && !Dir.exist?(pwn_skills_path.to_s)
468
618
 
469
619
  skills = {}
470
620
  return skills unless pwn_skills_path && Dir.exist?(pwn_skills_path.to_s)
471
621
 
622
+ # ── agentskills.io directory layout ───────────────────────────────
623
+ Dir.glob(File.join(pwn_skills_path, '*', SKILL_ENTRY)).each do |entry|
624
+ dir = File.dirname(entry)
625
+ key = File.basename(dir).to_sym
626
+ content = File.read(entry)
627
+ parsed = parse_skill_frontmatter(content: content)
628
+ fm = parsed[:frontmatter]
629
+ desc = (fm['description'] || fm[:description]).to_s.strip
630
+ desc = parsed[:body].to_s.lines.first.to_s.strip.sub(/^#+\s*/, '')[0, 200] if desc.empty?
631
+ scripts = Dir.glob(File.join(dir, 'scripts', '*.rb'))
632
+
633
+ meta = {
634
+ type: scripts.any? ? :ruby : :instruction,
635
+ format: :agentskills,
636
+ path: entry,
637
+ dir: dir,
638
+ content: content,
639
+ description: desc,
640
+ frontmatter: fm,
641
+ references: parse_skill_references(content: content),
642
+ allowed_tools: Array(fm['allowed-tools'] || fm[:'allowed-tools'] || fm['allowed_tools'])
643
+ }
644
+
645
+ scripts.each do |rb|
646
+ require rb
647
+ rescue StandardError => e
648
+ meta[:loaded] = false
649
+ meta[:error] = e.message
650
+ end
651
+ meta[:loaded] = true unless meta.key?(:loaded) || scripts.empty?
652
+
653
+ skills[key] = meta
654
+ end
655
+
656
+ # ── legacy flat files (backward-compat shim) ──────────────────────
472
657
  Dir.glob(File.join(pwn_skills_path, '*.{rb,md,txt,skill,yml,yaml}')).each do |skill_file|
473
- basename = File.basename(skill_file, '.*').to_sym
658
+ key = File.basename(skill_file, '.*').to_sym
659
+ next if skills.key?(key) # directory format wins on collision
660
+
474
661
  content = File.read(skill_file)
475
- ext = File.extname(skill_file).downcase
662
+ ext = File.extname(skill_file).downcase
663
+ parsed = parse_skill_frontmatter(content: content)
664
+ desc = parsed[:body].to_s.lines.reject { |l| l.strip.empty? || l.strip.start_with?('#', '---') }.first.to_s.strip
665
+ desc = parsed[:body].to_s.lines.first.to_s.strip.sub(/^#+\s*/, '')[0, 200] if desc.empty?
666
+
667
+ base = {
668
+ format: :legacy,
669
+ path: skill_file,
670
+ dir: pwn_skills_path,
671
+ content: content,
672
+ description: desc,
673
+ frontmatter: parsed[:frontmatter],
674
+ references: parse_skill_references(content: content)
675
+ }
476
676
 
477
677
  if ext == '.rb'
478
678
  begin
479
679
  require skill_file
480
- skills[basename] = { type: :ruby, path: skill_file, content: content, loaded: true, references: parse_skill_references(content: content) }
680
+ skills[key] = base.merge(type: :ruby, loaded: true)
481
681
  rescue StandardError => e
482
- skills[basename] = { type: :ruby, path: skill_file, content: content, loaded: false, error: e.message, references: parse_skill_references(content: content) }
682
+ skills[key] = base.merge(type: :ruby, loaded: false, error: e.message)
483
683
  end
484
684
  else
485
- skills[basename] = { type: :instruction, path: skill_file, content: content, references: parse_skill_references(content: content) }
685
+ skills[key] = base.merge(type: :instruction)
486
686
  end
487
687
  end
488
688
 
@@ -537,6 +737,21 @@ module PWN
537
737
  config: 'optional - Hash to redact sensitive artifacts from. Defaults to PWN::Env'
538
738
  )
539
739
 
740
+ #{self}.pwn_skills_path
741
+
742
+ #{self}.sanitize_skill_name(name: '...')
743
+
744
+ #{self}.write_skill(
745
+ name: 'required', content: 'required',
746
+ description: 'optional', references: 'optional Array',
747
+ license: 'optional', metadata: 'optional Hash',
748
+ allowed_tools: 'optional Array', pwn_skills_path: 'optional'
749
+ )
750
+
751
+ #{self}.load_skills(pwn_skills_path: 'optional')
752
+
753
+ #{self}.migrate_legacy_skills(pwn_skills_path: 'optional', delete_legacy: true)
754
+
540
755
  #{self}.refresh_env(
541
756
  pwn_env_path: 'optional - Path to pwn.yaml file. Defaults to ~/.pwn/pwn.yaml',
542
757
  pwn_dec_path: 'optional - Path to pwn.yaml.decryptor file. Defaults to ~/.pwn/pwn.yaml.decryptor'
data/lib/pwn/ffi/stdio.rb CHANGED
@@ -34,7 +34,9 @@ module PWN
34
34
  # Display Usage for this Module
35
35
 
36
36
  public_class_method def self.help
37
- puts "USAGE:
37
+ # NB: bare `puts` here would resolve to the FFI-attached libc puts
38
+ # (writes to C stdout, bypasses $stdout capture, flushes at exit).
39
+ $stdout.puts "USAGE:
38
40
  #{self}.puts string
39
41
  #{self}.printf(\"format string\", str, int, etc)
40
42
 
@@ -6,7 +6,9 @@ require 'htmlentities'
6
6
 
7
7
  module PWN
8
8
  module Plugins
9
- # This plugin was created to support fuzzing various networking protocols
9
+ # This plugin was created to support fuzzing various networking protocols.
10
+ # A request template with fuzz delimiters is combined with a payload and
11
+ # optional layered encodings, then replayed over TCP/UDP (optionally TLS).
10
12
  module Fuzz
11
13
  # Supported Method Parameters::
12
14
  # socket_fuzz_results_arr = PWN::Plugins::Fuzz.socket(
@@ -17,11 +19,11 @@ module PWN
17
19
  # fuzz_delimeter: 'optional - fuzz delimeter used in request to specify where payloads should reside (defaults to \u2665)',
18
20
  # request: 'required - String object of socket request w/ \u001A as fuzz delimeter (e.g. "GET /\u001A\u001A HTTP/1.1\r\nHost: \u001A127..0.0.1\u001A\r\n\r\n")',
19
21
  # payload: 'required - payload string',
22
+ # response_timeout: 'optional - float (defaults to 0.9)',
23
+ # request_rate_limit: 'optional - float (defaults to 0.3)',
20
24
  # encoding: 'optional - :base64 || :hex || :html_entity || :url (Defaults to nil)',
21
25
  # encoding_depth: 'optional - number of times to encode payload (defaults to 1)',
22
- # char_encoding: 'optional - character encoding returned by PWN::Plugins::Char.list_encoders (defaults to UTF-8)',
23
- # response_timeout: 'optional - float (defaults to 0.9)',
24
- # request_rate_limit: 'optional - float (defaults to 0.3)'
26
+ # char_encoding: 'optional - character encoding returned by PWN::Plugins::Char.list_encoders (defaults to UTF-8)'
25
27
  # )
26
28
 
27
29
  public_class_method def self.socket(opts = {})
@@ -189,11 +191,11 @@ module PWN
189
191
  fuzz_delimeter: \"optional - fuzz delimeter used in request to specify where payloads should reside (defaults to \u2665)\",
190
192
  request: \"required - String object of socket request w/ \u2665 as fuzz delimeter (e.g. '\"GET /\u2665\u2665 HTTP/1.1\\r\\nHost: \u2665127.0.0.1\u2665\\r\\n\\r\\n\"')\",
191
193
  payload: 'required - payload string',
194
+ response_timeout: 'optional - float (defaults to 0.9)',
195
+ request_rate_limit: 'optional - float (defaults to 0.3)',
192
196
  encoding: 'optional - :base64 || :hex || :html_entity || :url (Defaults to nil)',
193
197
  encoding_depth: 'optional - number of times to encode payload (defaults to 1)',
194
- char_encoding: 'optional - character encoding returned by PWN::Plugins::Char.list_encoders (defaults to UTF-8)',
195
- response_timeout: 'optional - float (defaults to 0.9)',
196
- request_rate_limit: 'optional - float (defaults to 0.3)'
198
+ char_encoding: 'optional - character encoding returned by PWN::Plugins::Char.list_encoders (defaults to UTF-8)'
197
199
  )
198
200
 
199
201
  #{self}.authors