pwn 0.5.783 → 0.5.785

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 (264) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/Gemfile +8 -8
  4. data/README.md +37 -0
  5. data/Rakefile +19 -1
  6. data/bin/pwn +2 -1
  7. data/bin/pwn_setup +1 -1
  8. data/bin/pwn_web_cache_deception +35 -7
  9. data/build_gem.sh +1 -1
  10. data/documentation/AI-Integration.md +7 -5
  11. data/documentation/Agent-Tool-Registry.md +2 -2
  12. data/documentation/Backup-Retention.md +82 -0
  13. data/documentation/Configuration.md +10 -6
  14. data/documentation/Contributing.md +1 -1
  15. data/documentation/Cron.md +64 -35
  16. data/documentation/Diagrams.md +2 -2
  17. data/documentation/General-PWN-Usage.md +4 -3
  18. data/documentation/Home.md +6 -5
  19. data/documentation/How-PWN-Works.md +7 -6
  20. data/documentation/Installation.md +254 -10
  21. data/documentation/Mission-Cancellation.md +134 -0
  22. data/documentation/Operability.md +17 -0
  23. data/documentation/Persistence.md +8 -6
  24. data/documentation/Plugin-Degradation.md +12 -0
  25. data/documentation/Plugins.md +3 -2
  26. data/documentation/Reinforcement-Learning.md +5 -6
  27. data/documentation/Runtime-Source-Protection.md +162 -0
  28. data/documentation/Transparent-Browser.md +11 -0
  29. data/documentation/Troubleshooting.md +3 -2
  30. data/documentation/What-is-PWN.md +2 -2
  31. data/documentation/YubiKey-Vaults.md +183 -0
  32. data/documentation/YubiKey.md +339 -0
  33. data/documentation/diagrams/cron-scheduling.svg +130 -128
  34. data/documentation/diagrams/dot/cron-scheduling.dot +5 -5
  35. data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
  36. data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
  37. data/documentation/diagrams/persistence-filesystem.svg +172 -142
  38. data/documentation/diagrams/plugin-ecosystem.svg +64 -64
  39. data/documentation/pwn-ai-Agent.md +10 -4
  40. data/etc/default_cron/session_cleanup.rb +1245 -22
  41. data/etc/default_skills/pwn/ai/agent/mission_namespace/SKILL.md +21 -0
  42. data/etc/default_skills/pwn/ai/agent/tools/browser_act/SKILL.md +45 -0
  43. data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +27 -26
  44. data/etc/default_skills/pwn/ai/agent/tools/recon_run/SKILL.md +25 -15
  45. data/etc/default_skills/pwn/ai/agent/tools/workspace_tool/SKILL.md +24 -0
  46. data/etc/default_skills/pwn/ai/server_tools/SKILL.md +25 -0
  47. data/etc/default_skills/pwn/backups/SKILL.md +54 -0
  48. data/etc/default_skills/pwn/plugins/recon/SKILL.md +12 -1
  49. data/etc/default_skills/pwn/plugins/yubikey/SKILL.md +157 -0
  50. data/etc/default_skills/pwn/plugins/yubikey/fido/SKILL.md +189 -0
  51. data/etc/default_skills/pwn/plugins/yubikey/hsmauth/SKILL.md +83 -0
  52. data/etc/default_skills/pwn/plugins/yubikey/management/SKILL.md +80 -0
  53. data/etc/default_skills/pwn/plugins/yubikey/oath/SKILL.md +115 -0
  54. data/etc/default_skills/pwn/plugins/yubikey/openpgp/SKILL.md +144 -0
  55. data/etc/default_skills/pwn/plugins/yubikey/otp/SKILL.md +97 -0
  56. data/etc/default_skills/pwn/plugins/yubikey/piv/SKILL.md +110 -0
  57. data/etc/default_skills/pwn/plugins/yubikey/protocol/SKILL.md +21 -0
  58. data/etc/default_skills/pwn/plugins/yubikey/security_domain/SKILL.md +75 -0
  59. data/etc/default_skills/pwn/plugins/yubikey/security_domain/scp/SKILL.md +46 -0
  60. data/etc/default_skills/pwn/plugins/yubikey/transport/SKILL.md +98 -0
  61. data/etc/default_skills/pwn/redaction/SKILL.md +13 -1
  62. data/lib/pwn/ai/agent/dispatch.rb +26 -15
  63. data/lib/pwn/ai/agent/extrospection.rb +6 -7
  64. data/lib/pwn/ai/agent/learning.rb +62 -25
  65. data/lib/pwn/ai/agent/loop.rb +17 -1
  66. data/lib/pwn/ai/agent/mission_namespace.rb +331 -0
  67. data/lib/pwn/ai/agent/policy.rb +5 -0
  68. data/lib/pwn/ai/agent/policy_evaluation.rb +8 -8
  69. data/lib/pwn/ai/agent/prompt_builder.rb +13 -2
  70. data/lib/pwn/ai/agent/request_runtime.rb +122 -18
  71. data/lib/pwn/ai/agent/reward.rb +5 -3
  72. data/lib/pwn/ai/agent/skill_consolidation.rb +3 -2
  73. data/lib/pwn/ai/agent/skill_review.rb +9 -5
  74. data/lib/pwn/ai/agent/swarm.rb +35 -16
  75. data/lib/pwn/ai/agent/tools/browser_act.rb +32 -0
  76. data/lib/pwn/ai/agent/tools/loot_query.rb +11 -1
  77. data/lib/pwn/ai/agent/tools/recon_run.rb +11 -1
  78. data/lib/pwn/ai/agent/tools/workspace_tool.rb +29 -0
  79. data/lib/pwn/ai/agent/turn_finalizer.rb +6 -2
  80. data/lib/pwn/ai/agent.rb +1 -0
  81. data/lib/pwn/ai/anthropic.rb +8 -33
  82. data/lib/pwn/ai/cli.rb +34 -5
  83. data/lib/pwn/ai/gemini.rb +1 -0
  84. data/lib/pwn/ai/grok.rb +39 -28
  85. data/lib/pwn/ai/ollama.rb +4 -2
  86. data/lib/pwn/ai/open_ai.rb +11 -32
  87. data/lib/pwn/ai/open_web_ui.rb +62 -4
  88. data/lib/pwn/ai/router.rb +53 -3
  89. data/lib/pwn/ai/server_tools.rb +231 -0
  90. data/lib/pwn/ai.rb +1 -0
  91. data/lib/pwn/backups.rb +347 -0
  92. data/lib/pwn/config.rb +70 -39
  93. data/lib/pwn/cron.rb +120 -49
  94. data/lib/pwn/migrate.rb +150 -68
  95. data/lib/pwn/module_skills.rb +7 -0
  96. data/lib/pwn/plugins/ai_sandbox.rb +538 -1
  97. data/lib/pwn/plugins/authentication_helper.rb +74 -4
  98. data/lib/pwn/plugins/basic_auth.rb +2 -1
  99. data/lib/pwn/plugins/binary_parser.rb +5 -4
  100. data/lib/pwn/plugins/ghidra.rb +4 -3
  101. data/lib/pwn/plugins/google_workspace.rb +4 -21
  102. data/lib/pwn/plugins/jobs.rb +1 -1
  103. data/lib/pwn/plugins/log.rb +21 -3
  104. data/lib/pwn/plugins/preflight_checker.rb +34 -9
  105. data/lib/pwn/plugins/recon.rb +4 -1
  106. data/lib/pwn/plugins/repl/ai.rb +305 -27
  107. data/lib/pwn/plugins/repl/ai_console.rb +273 -89
  108. data/lib/pwn/plugins/repl/ai_swarm.rb +181 -62
  109. data/lib/pwn/plugins/repl/mesh.rb +3 -16
  110. data/lib/pwn/plugins/repl/vault.rb +4 -15
  111. data/lib/pwn/plugins/repl.rb +1 -216
  112. data/lib/pwn/plugins/sandbox/driver.rb +4 -0
  113. data/lib/pwn/plugins/transparent_browser.rb +215 -0
  114. data/lib/pwn/plugins/vault.rb +412 -135
  115. data/lib/pwn/plugins/yubikey/fido.rb +1288 -0
  116. data/lib/pwn/plugins/yubikey/hsmauth.rb +439 -0
  117. data/lib/pwn/plugins/yubikey/management.rb +686 -0
  118. data/lib/pwn/plugins/yubikey/oath.rb +766 -0
  119. data/lib/pwn/plugins/yubikey/openpgp.rb +1063 -0
  120. data/lib/pwn/plugins/yubikey/otp.rb +897 -0
  121. data/lib/pwn/plugins/yubikey/piv.rb +1315 -0
  122. data/lib/pwn/plugins/yubikey/protocol.rb +341 -0
  123. data/lib/pwn/plugins/yubikey/security_domain/scp.rb +482 -0
  124. data/lib/pwn/plugins/yubikey/security_domain.rb +464 -0
  125. data/lib/pwn/plugins/yubikey/transport.rb +1017 -0
  126. data/lib/pwn/plugins/yubikey.rb +844 -0
  127. data/lib/pwn/plugins.rb +1 -0
  128. data/lib/pwn/redaction.rb +69 -12
  129. data/lib/pwn/session_trace.rb +4 -0
  130. data/lib/pwn/sessions.rb +58 -16
  131. data/lib/pwn/setup.rb +123 -41
  132. data/lib/pwn/version.rb +1 -1
  133. data/lib/pwn/www/app_cobalt_io.rb +16 -5
  134. data/lib/pwn/www/bug_crowd.rb +16 -5
  135. data/lib/pwn/www/coinbase_pro.rb +16 -5
  136. data/lib/pwn/www/github.rb +30 -14
  137. data/lib/pwn/www/paypal.rb +15 -4
  138. data/lib/pwn/www/synack.rb +16 -5
  139. data/lib/pwn/www/twitter.rb +17 -6
  140. data/lib/pwn.rb +1 -0
  141. data/scripts/build_gate.rb +58 -0
  142. data/spec/documentation/installation_md_spec.rb +1 -1
  143. data/spec/fixtures/yubikey/apdu_frames.json +162 -0
  144. data/spec/integration/authentication_helper_oath_spec.rb +117 -0
  145. data/spec/integration/build_gate_spec.rb +73 -0
  146. data/spec/integration/controller_spill_spec.rb +48 -0
  147. data/spec/integration/harness_process_spec.rb +97 -0
  148. data/spec/integration/mission_cancel_spec.rb +79 -0
  149. data/spec/integration/mission_curses_pty_spec.rb +44 -0
  150. data/spec/integration/persistence_roundtrip_spec.rb +1 -7
  151. data/spec/integration/readme_security_spec.rb +47 -0
  152. data/spec/integration/web_cache_deception_yubikey_spec.rb +159 -0
  153. data/spec/lib/pwn/ai/agent/dispatch_spec.rb +6 -3
  154. data/spec/lib/pwn/ai/agent/dispatch_vault_safety_spec.rb +86 -0
  155. data/spec/lib/pwn/ai/agent/injection_guard_spec.rb +2 -0
  156. data/spec/lib/pwn/ai/agent/learning_spec.rb +4 -4
  157. data/spec/lib/pwn/ai/agent/loop_artifacts_spec.rb +5 -0
  158. data/spec/lib/pwn/ai/agent/loop_spec.rb +104 -29
  159. data/spec/lib/pwn/ai/agent/mission_namespace_spec.rb +138 -0
  160. data/spec/lib/pwn/ai/agent/policy_spec.rb +27 -0
  161. data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +48 -0
  162. data/spec/lib/pwn/ai/agent/shell_search_effect_spec.rb +44 -0
  163. data/spec/lib/pwn/ai/agent/solve_spec.rb +6 -4
  164. data/spec/lib/pwn/ai/agent/tools/browser_act_spec.rb +44 -0
  165. data/spec/lib/pwn/ai/agent/tools/loot_query_yubikey_spec.rb +55 -0
  166. data/spec/lib/pwn/ai/agent/tools/recon_run_yubikey_spec.rb +43 -0
  167. data/spec/lib/pwn/ai/agent/tools/workspace_tool_spec.rb +20 -0
  168. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +7 -0
  169. data/spec/lib/pwn/ai/anthropic_vault_enrollment_spec.rb +37 -0
  170. data/spec/lib/pwn/ai/cli_spec.rb +6 -9
  171. data/spec/lib/pwn/ai/oauth_vault_persistence_spec.rb +195 -0
  172. data/spec/lib/pwn/ai/open_ai_spec.rb +2 -2
  173. data/spec/lib/pwn/ai/open_web_ui_workspace_spec.rb +36 -0
  174. data/spec/lib/pwn/ai/router_spec.rb +31 -3
  175. data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
  176. data/spec/lib/pwn/backups_spec.rb +114 -0
  177. data/spec/lib/pwn/config_redaction_yubikey_spec.rb +28 -0
  178. data/spec/lib/pwn/config_yubikey_spec.rb +92 -0
  179. data/spec/lib/pwn/cron_spec.rb +113 -6
  180. data/spec/lib/pwn/migrate_spec.rb +108 -5
  181. data/spec/lib/pwn/migrate_yubikey_spec.rb +128 -0
  182. data/spec/lib/pwn/module_skills_spec.rb +8 -0
  183. data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +230 -0
  184. data/spec/lib/pwn/plugins/authentication_helper_native_yubikey_spec.rb +40 -0
  185. data/spec/lib/pwn/plugins/authentication_helper_yubikey_spec.rb +90 -0
  186. data/spec/lib/pwn/plugins/capability_broker/daemon_spec.rb +1 -1
  187. data/spec/lib/pwn/plugins/google_workspace_spec.rb +17 -35
  188. data/spec/lib/pwn/plugins/log_spec.rb +7 -0
  189. data/spec/lib/pwn/plugins/log_yubikey_spec.rb +47 -0
  190. data/spec/lib/pwn/plugins/preflight_checker_native_yubikey_spec.rb +19 -0
  191. data/spec/lib/pwn/plugins/preflight_checker_yubikey_spec.rb +55 -0
  192. data/spec/lib/pwn/plugins/recon_yubikey_spec.rb +43 -0
  193. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +125 -78
  194. data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
  195. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +30 -16
  196. data/spec/lib/pwn/plugins/repl/ai_vault_edit_yubikey_spec.rb +103 -0
  197. data/spec/lib/pwn/plugins/repl/ai_yubikey_spec.rb +48 -0
  198. data/spec/lib/pwn/plugins/repl/mesh_vault_persistence_spec.rb +149 -0
  199. data/spec/lib/pwn/plugins/repl/vault_yaml_serialization_spec.rb +11 -0
  200. data/spec/lib/pwn/plugins/repl/vault_yubikey_spec.rb +39 -0
  201. data/spec/lib/pwn/plugins/repl_spec.rb +56 -5
  202. data/spec/lib/pwn/plugins/transparent_browser_jev_spec.rb +46 -0
  203. data/spec/lib/pwn/plugins/vault_native_yubikey_spec.rb +29 -0
  204. data/spec/lib/pwn/plugins/vault_publication_safety_spec.rb +258 -0
  205. data/spec/lib/pwn/plugins/vault_store_safety_spec.rb +241 -0
  206. data/spec/lib/pwn/plugins/vault_yubikey_safety_spec.rb +168 -0
  207. data/spec/lib/pwn/plugins/vault_yubikey_spec.rb +128 -0
  208. data/spec/lib/pwn/plugins/vault_yubikey_vector_spec.rb +45 -0
  209. data/spec/lib/pwn/plugins/yubikey/fido_spec.rb +854 -0
  210. data/spec/lib/pwn/plugins/yubikey/hsmauth_spec.rb +235 -0
  211. data/spec/lib/pwn/plugins/yubikey/management_spec.rb +277 -0
  212. data/spec/lib/pwn/plugins/yubikey/oath_spec.rb +349 -0
  213. data/spec/lib/pwn/plugins/yubikey/openpgp_spec.rb +810 -0
  214. data/spec/lib/pwn/plugins/yubikey/otp_spec.rb +337 -0
  215. data/spec/lib/pwn/plugins/yubikey/piv_spec.rb +646 -0
  216. data/spec/lib/pwn/plugins/yubikey/protocol_secure_spec.rb +28 -0
  217. data/spec/lib/pwn/plugins/yubikey/protocol_spec.rb +149 -0
  218. data/spec/lib/pwn/plugins/yubikey/protocol_vectors_spec.rb +18 -0
  219. data/spec/lib/pwn/plugins/yubikey/security_domain/scp_spec.rb +256 -0
  220. data/spec/lib/pwn/plugins/yubikey/security_domain/support.rb +153 -0
  221. data/spec/lib/pwn/plugins/yubikey/security_domain_spec.rb +296 -0
  222. data/spec/lib/pwn/plugins/yubikey/transport_spec.rb +760 -0
  223. data/spec/lib/pwn/plugins/yubikey_security_spec.rb +260 -0
  224. data/spec/lib/pwn/plugins/yubikey_spec.rb +209 -0
  225. data/spec/lib/pwn/plugins/yubikey_terminal_spec.rb +147 -0
  226. data/spec/lib/pwn/redaction_spec.rb +13 -10
  227. data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
  228. data/spec/lib/pwn/session_cleanup_concurrency_spec.rb +201 -0
  229. data/spec/lib/pwn/session_cleanup_default_spec.rb +167 -2
  230. data/spec/lib/pwn/session_cleanup_legacy_spec.rb +101 -0
  231. data/spec/lib/pwn/session_cleanup_payload_spec.rb +335 -2
  232. data/spec/lib/pwn/session_cleanup_recovery_spec.rb +403 -0
  233. data/spec/lib/pwn/sessions_spec.rb +112 -0
  234. data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
  235. data/spec/lib/pwn/setup_spec.rb +3 -1
  236. data/spec/lib/pwn/setup_vault_yubikey_spec.rb +61 -0
  237. data/spec/lib/pwn/setup_yubikey_spec.rb +40 -0
  238. data/spec/lib/pwn/www/login_yubikey_spec.rb +273 -0
  239. data/spec/spec_helper.rb +15 -0
  240. data/spec/support/gate_failure_formatter.rb +14 -0
  241. data/spec/support/harness/cli_faults.rb +13 -9
  242. data/spec/support/harness/controller_spill.rb +26 -0
  243. data/spec/support/harness/controller_state.rb +54 -0
  244. data/spec/support/harness/evaluate.rb +12 -1
  245. data/spec/support/harness/experiments.rb +1 -1
  246. data/spec/support/harness/fake_provider.rb +22 -6
  247. data/spec/support/harness/fault_preload.rb +3 -1
  248. data/spec/support/harness/mission_console.rb +54 -0
  249. data/spec/support/harness/mission_curses_pty.rb +41 -0
  250. data/spec/support/harness/mission_dispatch.rb +30 -0
  251. data/spec/support/harness/mission_fixture_preload.rb +24 -0
  252. data/spec/support/harness/mission_job_lifetime.rb +25 -0
  253. data/spec/support/harness/mission_legacy.rb +25 -0
  254. data/spec/support/harness/mission_normal_exit.rb +34 -0
  255. data/spec/support/harness/mission_swarm.rb +23 -0
  256. data/spec/support/harness/mission_unsupported.rb +29 -0
  257. data/spec/support/harness/package_acceptance.rb +33 -5
  258. data/spec/support/harness/session_acceptance.rb +4 -4
  259. data/spec/support/harness/swarm_acceptance.rb +1 -1
  260. data/spec/support/harness/swarm_control.rb +44 -0
  261. data/spec/support/mission_namespace.rb +14 -0
  262. data/spec/support/mission_pipe_queue.rb +33 -0
  263. data/third_party/pwn_rdoc.jsonl +599 -15
  264. metadata +140 -17
@@ -5,17 +5,31 @@ require 'open3'
5
5
  require 'timeout'
6
6
  require 'fileutils'
7
7
  require 'stringio'
8
+ require 'ffi'
9
+ require 'find'
10
+ require 'tmpdir'
11
+ require 'socket'
8
12
 
9
13
  module PWN
10
14
  module Plugins
11
15
  # Forked worker for model-authored shell/Ruby with Landlock/seccomp profiles.
12
16
  module AISandbox
17
+ ALIAS_MUTEX = Mutex.new
18
+ ALIAS_CACHE = {} # rubocop:disable Style/MutableConstant -- host-only inode fingerprint cache
19
+
20
+ NotStarted = PWN::AI::Agent::MissionNamespace::Unavailable
21
+
22
+ # One kernel-owned process tree. Namespace PID 1 owns every descendant,
23
+ # including double forks and setsid jobs. Reaping PID 1 completes teardown.
24
+ # No process-group fallback: unsupported hosts never execute the block.
25
+ MissionProcess = PWN::AI::Agent::MissionNamespace
26
+
13
27
  WRITE_RX = %r{\b(?:rm|rmdir|unlink|shred|truncate)\b|FileUtils\.rm|File\.(?:delete|unlink)|Pathname.*rmtree|>\s*/}
14
28
  NET_RX = /\b(?:curl|wget|nc|ncat|ssh|scp|ftp|telnet)\b|TCPSocket|UDPSocket|Net::HTTP|Socket\.(?:tcp|udp)/
15
29
  PATH_RX = %r{FileUtils\.\w+\(\s*['"]([^'"]+)['"]|(?:^|\s)(?:rm|rmdir)\s+(?:-[a-zA-Z]+\s+)*([^\s;|&]+)|['"](/(?:[^'"]+))['"]}
16
30
 
17
31
  public_class_method def self.required_bins
18
- []
32
+ %w[unshare]
19
33
  end
20
34
 
21
35
  # Read the ai_sandbox key as strict, permissive, or off.
@@ -61,12 +75,535 @@ module PWN
61
75
  exec(opts.merge(kind: :ruby, payload: opts[:code] || opts[:payload]))
62
76
  end
63
77
 
78
+ # Pin each controller directory component without following symlinks.
79
+ # Callers must use NOFOLLOW/EXCL for final files within the yielded fd path.
80
+ public_class_method def self.control_directory(opts = {})
81
+ raise NotStarted, 'safe controller persistence requires Linux openat' unless RUBY_PLATFORM.include?('linux')
82
+
83
+ native = Module.new do
84
+ extend ::FFI::Library
85
+
86
+ ffi_lib ::FFI::Library::LIBC
87
+ attach_function :openat, %i[int string int uint], :int
88
+ attach_function :mkdirat, %i[int string uint], :int
89
+ end
90
+ flags = File::RDONLY | File::NOFOLLOW | 0x10000 | 0x80000 # O_DIRECTORY | O_CLOEXEC
91
+ identities = protected_roots.map do |path|
92
+ stat = File.stat(path)
93
+ [stat.dev, stat.ino]
94
+ end
95
+ directory = IO.new(IO.sysopen('/', flags))
96
+ File.expand_path(opts.fetch(:path)).split('/').reject(&:empty?).each do |part|
97
+ fd = native.openat(directory.fileno, part, flags, 0)
98
+ if fd.negative? && ::FFI.errno == Errno::ENOENT::Errno
99
+ result = native.mkdirat(directory.fileno, part, 0o700)
100
+ raise SystemCallError.new('controller mkdirat', ::FFI.errno) if result.negative? && ::FFI.errno != Errno::EEXIST::Errno
101
+
102
+ fd = native.openat(directory.fileno, part, flags, 0)
103
+ end
104
+ raise SystemCallError.new('controller openat (symlink ancestry is forbidden)', ::FFI.errno) if fd.negative?
105
+
106
+ child = IO.new(fd)
107
+ directory.close
108
+ directory = child
109
+ stat = directory.stat
110
+ raise NotStarted, 'controller persistence path enters protected source' if identities.include?([stat.dev, stat.ino])
111
+ end
112
+ yield "/proc/self/fd/#{directory.fileno}"
113
+ ensure
114
+ directory&.close unless directory&.closed?
115
+ end
116
+
117
+ # Mandatory repository boundary, independent of the optional profile.
118
+ # The block runs only in a child; cwd and restrictions never affect the UI.
119
+ public_class_method def self.protect_call(opts = {})
120
+ return yield if Thread.current[:pwn_repository_worker]
121
+ return protect_descriptor(opts) unless PWN::AI::Agent::MissionNamespace.owned || (PWN::AI::Agent::MissionNamespace.prepared? && !PWN::AI::Agent::MissionNamespace.force_exec)
122
+
123
+ roots = Array(opts[:roots] || protected_roots).map { |path| File.realpath(path) }.uniq
124
+ scratch = workspace
125
+ FileUtils.mkdir_p(scratch, mode: 0o700)
126
+ scratch = File.realpath(scratch)
127
+ task_dir = File.expand_path(opts[:workspace] || scratch)
128
+ ancestor = task_dir
129
+ ancestor = File.dirname(ancestor) until File.exist?(ancestor)
130
+ resolved = File.join(File.realpath(ancestor), task_dir.delete_prefix(ancestor).delete_prefix('/'))
131
+ raise SecurityError, 'runtime workspace is inside protected source' if roots.any? { |root| resolved == root || resolved.start_with?("#{root}/") }
132
+
133
+ FileUtils.mkdir_p(task_dir, mode: 0o700)
134
+ task_dir = File.realpath(task_dir)
135
+ if opts[:runtime]
136
+ provision_swarm_state
137
+ controller_root = File.dirname(opts[:runtime].checkpoint_path, 2)
138
+ roots += [File.realpath(controller_root), File.realpath(PWN::AI::Agent::Swarm::SWARM_ROOT), File.realpath(PWN::AI::Agent::Swarm::AGENTS_FILE)]
139
+ end
140
+ roots.concat(controller_state_roots)
141
+ explicit = prepare_write_roots(paths: opts[:write_roots])
142
+ writable = [scratch, '/dev/null', '/dev/zero', '/dev/random', '/dev/urandom'] + explicit
143
+ writable += prepare_write_roots(paths: state_write_roots) unless opts.dig(:handler, :name).to_s == 'shell'
144
+ writable = writable.filter_map { |path| File.realpath(path) if File.exist?(path) && !File.symlink?(path) }.uniq
145
+ writable.reject! { |path| %w[/ /proc /sys /dev].include?(path) }
146
+ aliases, identities = repository_aliases(roots: roots, writable: writable)
147
+
148
+ reader, writer = IO.pipe
149
+ controller, client = UNIXSocket.pair
150
+ mission = nil
151
+ Thread.handle_interrupt(Exception => :never) do
152
+ mission = MissionProcess.new(inherit_namespace: true) do
153
+ reader.close
154
+ controller.close
155
+ Thread.current[:pwn_swarm_controller] = client if opts[:runtime]
156
+ Signal.trap('TERM') { raise Interrupt, 'runtime worker cancelled' }
157
+ begin
158
+ repository_restrict!(roots: roots + aliases, writable: writable, explicit: explicit)
159
+ repository_metadata_restrict!(roots: roots + aliases, identities: identities)
160
+ Dir.chdir(task_dir)
161
+ ENV['TMPDIR'] = scratch
162
+ Thread.current[:pwn_repository_worker] = true
163
+ opts[:runtime]&.worker_view!
164
+ started = true
165
+ result = yield
166
+ provenance = opts[:runtime]&.observations&.transform_values { |row| row.except(:head, :tail) } || {}
167
+ writer.write(JSON.generate(started: true, provenance: provenance, result: PWN::AI::Agent::Result.utf8_value(value: result)))
168
+ rescue StandardError, SecurityError, NotImplementedError => e
169
+ writer.write(JSON.generate(started: started == true, result: { success: false, error: "repository protection: #{e.class}: #{e.message}" }))
170
+ ensure
171
+ writer.close
172
+ exit!
173
+ end
174
+ end
175
+ end
176
+ writer.close
177
+ client.close
178
+ raw = collect_worker(reader: reader, controller: controller, runtime: opts[:runtime])
179
+ mission.wait
180
+ envelope = JSON.parse(raw, symbolize_names: true)
181
+ raise NotStarted, envelope.dig(:result, :error) unless envelope[:started]
182
+
183
+ opts[:runtime]&.worker_provenance!(rows: (envelope[:provenance] || {}).transform_keys(&:to_s).merge(opts[:runtime].observations))
184
+ envelope[:result]
185
+ rescue NotImplementedError, SecurityError, SystemCallError => e
186
+ raise NotStarted, "repository protection unavailable: #{e.message}"
187
+ ensure
188
+ reader&.close unless reader&.closed?
189
+ writer&.close unless writer&.closed?
190
+ mission&.close
191
+ controller&.close unless controller&.closed?
192
+ client&.close unless client&.closed?
193
+ end
194
+
195
+ private_class_method def self.protect_descriptor(opts = {})
196
+ raise NotStarted, 'protected host calls require a serializable handler descriptor' unless opts[:handler]
197
+
198
+ reader, writer = IO.pipe
199
+ controller, client = UNIXSocket.pair
200
+ descriptor = { operation: :protected, options: opts.except(:runtime), runtime: opts[:runtime]&.mission_snapshot }
201
+ mission = MissionProcess.new(descriptor: descriptor, fds: { 4 => writer, 5 => client })
202
+ writer.close
203
+ client.close
204
+ raw = collect_worker(reader: reader, controller: controller, runtime: opts[:runtime])
205
+ mission.wait
206
+ envelope = JSON.parse(raw, symbolize_names: true)
207
+ raise NotStarted, envelope[:error] if envelope[:error]
208
+
209
+ opts[:runtime]&.worker_provenance!(rows: envelope.fetch(:provenance, {}).transform_keys(&:to_s))
210
+ envelope[:result]
211
+ ensure
212
+ mission&.close
213
+ [reader, writer, controller, client].compact.each { |io| io.close unless io.closed? }
214
+ end
215
+
216
+ private_class_method def self.mission_entry(opts = {})
217
+ writer = PWN::AI::Agent::MissionNamespace.io(4)
218
+ Thread.current[:pwn_swarm_controller] = PWN::AI::Agent::MissionNamespace.io(5)
219
+ runtime = PWN::AI::Agent::RequestRuntime.send(:from_mission_snapshot, state: opts[:runtime]) if opts[:runtime]
220
+ Thread.current[:pwn_request_runtime] = runtime
221
+ options = opts.fetch(:options)
222
+ handler = options.fetch(:handler)
223
+ PWN::AI::Agent::Registry.discover
224
+ begin
225
+ result = protect_call(options.merge(runtime: runtime)) do
226
+ PWN::AI::Agent::Registry.lookup(name: handler.fetch(:name)).handler.call(handler.fetch(:args))
227
+ end
228
+ writer.write(JSON.generate(result: result, provenance: runtime&.observations || {}))
229
+ rescue StandardError => e
230
+ writer.write(JSON.generate(error: "#{e.class}: #{e.message}"))
231
+ ensure
232
+ writer.close
233
+ end
234
+ end
235
+
236
+ # Typed swarm control operations; never execute a registry handler on the host.
237
+ public_class_method def self.swarm_control(opts = {})
238
+ socket = Thread.current[:pwn_swarm_controller]
239
+ raise NotStarted, 'swarm controller unavailable' unless socket
240
+
241
+ socket.puts(JSON.generate(operation: opts.fetch(:operation), args: opts.fetch(:args)))
242
+ reply = JSON.parse(socket.gets || '{}', symbolize_names: true)
243
+ raise NotStarted, reply[:error] if reply[:error]
244
+
245
+ reply.fetch(:result)
246
+ end
247
+
248
+ private_class_method def self.provision_swarm_state
249
+ swarm = PWN::AI::Agent::Swarm
250
+ control_directory(path: swarm::SWARM_ROOT) { |_directory| nil }
251
+ control_directory(path: File.dirname(swarm::AGENTS_FILE)) do |directory|
252
+ [File.basename(swarm::AGENTS_FILE), '.swarm-continuations.lock'].each do |name|
253
+ File.open(File.join(directory, name), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |file|
254
+ raise NotStarted, 'swarm state must be a singly linked regular file' unless file.stat.file? && file.stat.nlink == 1
255
+ end
256
+ end
257
+ end
258
+ end
259
+
260
+ private_class_method def self.collect_worker(opts = {})
261
+ reader = opts.fetch(:reader)
262
+ controller = opts.fetch(:controller)
263
+ streams = [reader, controller]
264
+ bytes = +''
265
+ loop do
266
+ IO.select(streams).first.each do |stream|
267
+ if stream == reader
268
+ chunk = reader.read_nonblock(65_536, exception: false)
269
+ return bytes if chunk.nil?
270
+
271
+ bytes << chunk unless chunk == :wait_readable
272
+ else
273
+ line = controller.gets(65_537)
274
+ if line.nil?
275
+ streams.delete(controller)
276
+ next
277
+ end
278
+ reply = swarm_control_reply(line: line, runtime: opts[:runtime])
279
+ controller.puts(JSON.generate(reply))
280
+ end
281
+ end
282
+ end
283
+ end
284
+
285
+ private_class_method def self.swarm_control_reply(opts = {})
286
+ raise NotStarted, 'swarm controller requires a request runtime' unless opts[:runtime]
287
+ raise ArgumentError, 'swarm control frame too large' if opts.fetch(:line).bytesize > 65_536
288
+
289
+ frame = JSON.parse(opts.fetch(:line), symbolize_names: true)
290
+ operation = frame.fetch(:operation)
291
+ opts[:runtime].check_cancelled!
292
+ raise ArgumentError, 'swarm control permission denied' unless opts[:runtime].tool_allowed?(name: "agent_#{operation}", toolset: 'swarm')
293
+
294
+ fields = case operation
295
+ when 'spawn' then %i[name role toolsets engine model max_iters]
296
+ when 'ask' then %i[name request swarm_id unit ttl engagement_id]
297
+ else raise ArgumentError, 'unsupported swarm control operation'
298
+ end
299
+ args = frame.fetch(:args)
300
+ raise ArgumentError, 'unsupported swarm control fields' unless args.is_a?(Hash) && (args.keys - fields).empty?
301
+
302
+ %i[swarm_id engagement_id].each do |key|
303
+ next if args[key].nil?
304
+ raise ArgumentError, 'invalid swarm control identifier' unless args[key].is_a?(String) && args[key].match?(/\A[a-zA-Z0-9_-]+\z/)
305
+ end
306
+ swarm = PWN::AI::Agent::Swarm
307
+ control_directory(path: swarm::SWARM_ROOT) do |_directory|
308
+ control_directory(path: File.join(swarm::SWARM_ROOT, args[:swarm_id])) { |_child| nil } if args[:swarm_id]
309
+ end
310
+ result = operation == 'spawn' ? swarm.spawn(args) : swarm.ask(args)
311
+ { result: result }
312
+ rescue StandardError => e
313
+ { error: "swarm controller: #{e.class}: #{e.message}" }
314
+ end
315
+
316
+ # Default new-file directory. Process cwd is not an implicit write grant.
317
+ public_class_method def self.workspace
318
+ File.expand_path(File.join(Dir.home, '.pwn', 'workspaces', 'runtime'))
319
+ end
320
+
321
+ # Relative reads use a safe task directory. Protected-source launches use
322
+ # the runtime workspace instead.
323
+ public_class_method def self.task_directory
324
+ cwd = File.realpath(Dir.pwd)
325
+ protected = protected_roots.any? { |root| cwd == root || cwd.start_with?("#{root}/") || root.start_with?("#{cwd}/") }
326
+ return workspace if cwd == '/' || protected
327
+
328
+ cwd
329
+ end
330
+
331
+ # Materialize operator-named destinations. A file literal grants its parent
332
+ # so creation works; a directory literal grants itself. Protected source
333
+ # and symlinks are never granted.
334
+ public_class_method def self.prepare_write_roots(opts = {})
335
+ roots = protected_roots
336
+ Array(opts[:paths]).filter_map do |literal|
337
+ path = File.expand_path(literal.to_s)
338
+ next if protected_path?(path: path, roots: roots)
339
+
340
+ # An existing directory is the grant. A file, or a destination that does
341
+ # not exist yet, grants its parent so mv/touch can create the file.
342
+ # Do not mkdir a missing extensionless path; that would turn
343
+ # `mv foo /tmp/test` into a move into a directory.
344
+ grant = File.directory?(path) && !File.symlink?(path) ? path : File.dirname(path)
345
+ next if protected_path?(path: grant, roots: roots) || %w[/ /proc /sys /dev].include?(grant)
346
+ next if File.symlink?(grant)
347
+
348
+ FileUtils.mkdir_p(grant, mode: 0o700) unless File.directory?(grant)
349
+ next unless File.directory?(grant) && !File.symlink?(grant)
350
+
351
+ resolved = File.realpath(grant)
352
+ next if protected_path?(path: resolved, roots: roots)
353
+
354
+ resolved
355
+ end.uniq
356
+ end
357
+
358
+ # Detect both the conventional checkout and the loaded package source.
359
+ public_class_method def self.protected_roots
360
+ ['/opt/pwn', File.expand_path('../../..', __dir__)].select { |path| File.directory?(path) }.map { |path| File.realpath(path) }.uniq
361
+ end
362
+
363
+ private_class_method def self.protected_path?(opts = {})
364
+ path = opts[:path]
365
+ opts.fetch(:roots).any? { |root| path == root || path.start_with?("#{root}/") }
366
+ end
367
+
368
+ # Plugin records, not the default scratch tree. Shell does not receive these.
369
+ private_class_method def self.state_write_roots
370
+ denied = controller_state_roots
371
+ %w[jobs cache ghidra redb binws wordlists exports sandbox_snapshots intel].filter_map do |name|
372
+ path = File.join(Dir.home, '.pwn', name)
373
+ next if protected_path?(path: path, roots: denied)
374
+
375
+ path
376
+ end
377
+ end
378
+
379
+ # Keep controller persistence out of tool grants, including absent paths.
380
+ # A missing deny still splits its ancestor grant, so tools cannot plant it
381
+ # before the controller's first append. Honour test/custom store constants.
382
+ private_class_method def self.controller_state_roots
383
+ agent = PWN::AI::Agent
384
+ stores = [PWN::Sessions, PWN::Memory, PWN::MemoryIndex, PWN::Plugins::ArtifactRegistry,
385
+ agent::Learning, agent::Policy, agent::Metrics, agent::Reward, agent::Mistakes,
386
+ agent::OpenGoal, agent::Mission, agent::TaskDAG, agent::SkillReview, agent::Engagement,
387
+ agent::Curriculum, agent::ToolGuard, agent::Extrospection, agent::Swarm]
388
+ paths = stores.flat_map do |store|
389
+ store.constants(false).filter_map do |name|
390
+ next unless name.to_s.match?(/(?:_FILE|_DIR|_ROOT|LEDGER|ROOT)\z/)
391
+
392
+ value = store.const_get(name, false)
393
+ value if value.is_a?(String) && value.start_with?('/')
394
+ end
395
+ end
396
+ paths.concat(%w[artifacts logs learning metrics skills .swarm-continuations.lock pwn.yaml pwn.yaml.decryptor vault-secrets.json].map { |name| File.join(Dir.home, '.pwn', name) })
397
+ paths << File.join(Dir.home, '.pwn-vault.key')
398
+ if defined?(PWN::Env) && PWN::Env.is_a?(Hash)
399
+ %i[pwn_env_path pwn_dec_path pwn_skills_path].each do |key|
400
+ path = PWN::Env.dig(:driver_opts, key)
401
+ paths << path unless path.to_s.empty?
402
+ end
403
+ end
404
+ paths.concat(PWN::Skills.values.filter_map { |meta| File.dirname(meta[:path]) if meta.is_a?(Hash) && meta[:path].is_a?(String) }) if defined?(PWN::Skills) && PWN::Skills.is_a?(Hash)
405
+ paths.flat_map do |path|
406
+ path = File.expand_path(path)
407
+ ancestor = path
408
+ ancestor = File.dirname(ancestor) until File.exist?(ancestor) || File.symlink?(ancestor)
409
+ # Preserve the lexical deny (including absent paths), and also deny
410
+ # the canonical store behind an operator-configured symlink.
411
+ [path, "#{File.realpath(ancestor)}#{path.delete_prefix(ancestor)}"]
412
+ end.uniq
413
+ end
414
+
415
+ private_class_method def self.repository_aliases(opts = {})
416
+ identities = {}
417
+ opts.fetch(:roots).each do |root|
418
+ next unless File.exist?(root)
419
+
420
+ Find.find(root) do |path|
421
+ stat = File.lstat(path)
422
+ identities[[stat.dev, stat.ino]] = [stat.nlink, stat.ctime.to_r] if stat.file? && stat.nlink > 1
423
+ end
424
+ end
425
+ return [[], []] if identities.empty?
426
+
427
+ aliases = opts.fetch(:writable).flat_map do |root|
428
+ stat = File.stat(root)
429
+ key = [identities, root, stat.dev, stat.ino]
430
+ ALIAS_MUTEX.synchronize do
431
+ ALIAS_CACHE.shift if ALIAS_CACHE.length >= 64
432
+ ALIAS_CACHE[key] ||= repository_scan_aliases(root: root, identities: identities).freeze
433
+ end
434
+ end.uniq
435
+ [aliases, identities.keys]
436
+ end
437
+
438
+ private_class_method def self.repository_scan_aliases(opts = {})
439
+ aliases = []
440
+ Find.find(opts.fetch(:root)) do |path|
441
+ stat = File.lstat(path)
442
+ if stat.directory? && (!File.readable?(path) || !File.executable?(path))
443
+ aliases << path
444
+ Find.prune
445
+ end
446
+ aliases << path if stat.file? && opts.fetch(:identities)[[stat.dev, stat.ino]]
447
+ rescue Errno::ENOENT
448
+ next
449
+ end
450
+ aliases
451
+ end
452
+
453
+ private_class_method def self.repository_metadata_restrict!(opts = {})
454
+ roots = opts.fetch(:roots)
455
+ # Landlock does not mediate chmod/chown/xattr/time metadata. Deny those
456
+ # syscalls conservatively, also outside source, rather than claim a
457
+ # path regex makes arbitrary Ruby safe. Content writes remain usable.
458
+ native = Module.new do
459
+ extend ::FFI::Library
460
+
461
+ ffi_lib 'libseccomp.so.2'
462
+ attach_function :seccomp_init, [:uint32], :pointer
463
+ attach_function :seccomp_syscall_resolve_name, [:string], :int
464
+ attach_function :seccomp_rule_add, %i[pointer uint32 int uint], :int
465
+ attach_function :seccomp_load, [:pointer], :int
466
+ attach_function :seccomp_release, [:pointer], :void
467
+ end
468
+ context = native.seccomp_init(0x7fff0000)
469
+ raise SecurityError, 'seccomp initialization failed' if context.null?
470
+
471
+ begin
472
+ %w[chmod fchmod fchmodat fchmodat2 chown fchown lchown fchownat
473
+ utime utimes futimesat utimensat setxattr lsetxattr fsetxattr
474
+ removexattr lremovexattr fremovexattr mount umount2 pivot_root
475
+ move_mount open_tree fsopen fsconfig fsmount mount_setattr unshare
476
+ setns ptrace process_vm_writev io_uring_setup].each do |name|
477
+ number = native.seccomp_syscall_resolve_name(name)
478
+ next if number.negative?
479
+ raise SecurityError, "seccomp rule failed: #{name}" unless native.seccomp_rule_add(context, 0x50000 | 1, number, 0).zero?
480
+ end
481
+ raise SecurityError, 'seccomp load failed' unless native.seccomp_load(context).zero?
482
+ ensure
483
+ native.seccomp_release(context)
484
+ end
485
+ # Close descriptors opened before Landlock; its policy applies to open,
486
+ # not writes through an already-open file description.
487
+ Dir.children('/proc/self/fd').each do |number|
488
+ fd = number.to_i
489
+ begin
490
+ path = File.realpath("/proc/self/fd/#{fd}")
491
+ stat = File.stat("/proc/self/fd/#{fd}")
492
+ IO.new(fd).close if opts.fetch(:identities).include?([stat.dev, stat.ino]) || roots.any? { |root| path == root || path.start_with?("#{root}/") }
493
+ rescue Errno::ENOENT, Errno::EBADF
494
+ nil
495
+ end
496
+ end
497
+ :restricted
498
+ rescue LoadError => e
499
+ raise SecurityError, "libseccomp is required: #{e.message}"
500
+ end
501
+
502
+ private_class_method def self.repository_restrict!(opts = {})
503
+ raise NotImplementedError, 'Linux Landlock ABI 3 or newer is required' unless RUBY_PLATFORM.match?(/(?:x86_64|aarch64).*linux/)
504
+
505
+ abi = syscall(444, 0, 0, 1)
506
+ raise NotImplementedError, 'Linux Landlock ABI 3 or newer is required' if abi < 3
507
+
508
+ # Handle every filesystem content/creation/removal right, including
509
+ # REFER and TRUNCATE. Read and execute remain available everywhere.
510
+ rights = (1 << 1) | (((1 << 15) - 1) & ~15)
511
+ attr = ::FFI::MemoryPointer.from_string([rights].pack('Q'))
512
+ ruleset = syscall(444, attr.address, 8, 0)
513
+ roots = opts.fetch(:roots)
514
+ # Compile exact denies and their ancestors once. Hardlink inventories can
515
+ # contain thousands of files; rescanning every deny at every tree node
516
+ # makes otherwise bounded namespace startup quadratic.
517
+ denied = roots.to_h { |path| [path, true] }
518
+ ancestors = {}
519
+ roots.each do |root|
520
+ parent = File.dirname(root)
521
+ loop do
522
+ ancestors[parent] = true
523
+ break if parent == '/'
524
+
525
+ parent = File.dirname(parent)
526
+ end
527
+ end
528
+ begin
529
+ opts.fetch(:writable).each do |path|
530
+ next if roots.any? { |root| path == root || path.start_with?("#{root}/") }
531
+
532
+ repository_allow!(path: path, denied: denied, ancestors: ancestors, ruleset: ruleset, rights: rights, force: opts[:explicit]&.include?(path))
533
+ end
534
+ prctl = RUBY_PLATFORM.start_with?('aarch64') ? 167 : 157
535
+ syscall(prctl, 38, 1, 0, 0, 0)
536
+ syscall(446, ruleset, 0)
537
+ ensure
538
+ IO.new(ruleset).close
539
+ end
540
+ :restricted
541
+ end
542
+
543
+ private_class_method def self.repository_allow!(opts = {})
544
+ path = opts.fetch(:path)
545
+ return if opts.fetch(:denied).key?(path)
546
+ return if File.symlink?(path)
547
+
548
+ if opts.fetch(:ancestors).key?(path) && !opts[:force]
549
+ Dir.children(path).each { |name| repository_allow!(opts.merge(path: File.join(path, name), force: false)) }
550
+ else
551
+ rights = opts.fetch(:rights)
552
+ rights &= (1 << 1) | (1 << 14) unless File.directory?(path)
553
+ fd = IO.sysopen(path, 0x200000 | 0x80000) # O_PATH | O_CLOEXEC
554
+ begin
555
+ attr = ::FFI::MemoryPointer.from_string([rights, fd].pack('QL'))
556
+ syscall(445, opts.fetch(:ruleset), 1, attr.address, 0)
557
+ ensure
558
+ IO.new(fd).close
559
+ end
560
+ end
561
+ :allowed
562
+ rescue Errno::ENOENT
563
+ # Unrelated sibling scratch entries can disappear during enumeration.
564
+ # Omitting an allow rule is conservative; never grant its parent.
565
+ :absent
566
+ end
567
+
64
568
  public_class_method def self.authors
65
569
  "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
66
570
  end
67
571
 
68
572
  public_class_method def self.help
69
573
  puts "USAGE:
574
+ # Pin controller ancestry with no-follow openat directory descriptors.
575
+ #{self}.control_directory(
576
+ path: 'required - controller directory, created with mode 0700 if absent'
577
+ ) { |fd_path| persist_with_nofollow(fd_path) }
578
+
579
+ # Execute a runtime handler behind mandatory Linux source protection.
580
+ #{self}.protect_call(
581
+ roots: 'optional - host-owned protected roots; defaults to checkout and loaded package',
582
+ workspace: 'optional - host-owned cwd; defaults to ~/.pwn/workspaces/runtime',
583
+ write_roots: 'optional - destinations named by the original operator request',
584
+ runtime: 'optional - host-owned request runtime; child checkpoint writes stay isolated'
585
+ ) { handler.call }
586
+
587
+ # Request one typed swarm operation from the supervising controller.
588
+ #{self}.swarm_control(
589
+ operation: 'required - spawn or ask; no arbitrary method or handler dispatch',
590
+ args: 'required - JSON-compatible allowlisted swarm tool arguments'
591
+ )
592
+
593
+ # Return the default directory for unnamed new files.
594
+ #{self}.workspace
595
+
596
+ # Return the task directory used for relative reads.
597
+ #{self}.task_directory
598
+
599
+ # Create and return grants for operator-named write destinations.
600
+ #{self}.prepare_write_roots(
601
+ paths: 'required - absolute or expandable destinations from the original request'
602
+ )
603
+
604
+ # Return detected checkout and installed package source roots.
605
+ #{self}.protected_roots
606
+
70
607
  # List host binaries this module expects to be installed.
71
608
  #{self}.required_bins
72
609
 
@@ -7,7 +7,11 @@ module PWN
7
7
  # This plugin is used to assist in masking a password when entered in via
8
8
  # STDIN to prevent would-be shoulder surfers from obtaining password
9
9
  # information. This plugin is useful when demonstrating the functionality
10
- # of other SP plugins/modules.
10
+ # of other SP plugins/modules. MFA can explicitly select a YubiKey OATH
11
+ # account without storing its secret; device failures never fall back to a prompt.
12
+ # Native OATH requires explicit confirm: true because HOTP advances counters
13
+ # and password-protected accounts can require authentication. Passwords and
14
+ # secret providers remain in memory; no automatic deletion or repair occurs.
11
15
  module AuthenticationHelper
12
16
  # Supported Method Parameters::
13
17
  # PWN::Plugins::AuthenticationHelper.username(
@@ -40,20 +44,74 @@ module PWN
40
44
 
41
45
  # Supported Method Parameters::
42
46
  # PWN::Plugins::AuthenticationHelper.mfa(
43
- # prompt: 'optional - string to display at prompt (Default: MFA Token)'
47
+ # prompt: 'optional - string to display at prompt (Default: MFA Token)',
48
+ # yubikey: 'optional - Hash selecting an exact OATH account (required :account and literal :confirm true); optional :serial exact decimal String, :reader or :device_path nonempty control-free String, :connection native exchange object, :timeout finite positive seconds (default 30), in-memory :password or callable :secret_provider; serial/reader and reader/device_path are mutually exclusive; no OTP slot; explicit mfa/mfa_token values conflict'
44
49
  # )
45
50
 
46
51
  public_class_method def self.mfa(opts = {})
52
+ validate_mfa_options(opts)
53
+ return PWN::Plugins::YubiKey.oath_code(opts[:yubikey]) unless opts[:yubikey].nil?
54
+
47
55
  prompt = opts[:prompt] ||= 'MFA Token'
48
56
 
49
57
  mfa = TTY::Prompt.new.ask("#{prompt}: ")
50
58
  mfa.to_s.strip.chomp.scrub
51
59
  rescue Interrupt
60
+ raise unless opts[:yubikey].nil?
61
+
52
62
  puts "#{self}.#{__method__} => Goodbye."
53
63
  rescue StandardError => e
54
64
  raise e
55
65
  end
56
66
 
67
+ # Validate MFA source selection before login without touching a device.
68
+ public_class_method def self.validate_mfa_options(opts = {})
69
+ yubikey = opts[:yubikey]
70
+ return opts if yubikey.nil?
71
+
72
+ raise ArgumentError, 'yubikey must be a Hash with an exact OATH account' unless yubikey.is_a?(Hash)
73
+ raise ArgumentError, 'yubikey slot is not an OATH option; use the exact account name' if yubikey.key?(:slot) || yubikey.key?('slot')
74
+
75
+ account = yubikey[:account]
76
+ raise ArgumentError, 'yubikey account must be a nonempty exact OATH name without control characters' unless account.is_a?(String) && account.valid_encoding? && !account.strip.empty? && !account.match?(/[[:cntrl:]]/)
77
+
78
+ raise ArgumentError, 'yubikey cannot be combined with an explicit mfa or mfa_token' if !opts[:mfa_token].to_s.strip.empty? || (opts[:mfa].is_a?(String) && !opts[:mfa].strip.empty?)
79
+ raise ArgumentError, 'yubikey requires confirm: true: HOTP advances counters and protected OATH accounts may require authentication' unless yubikey[:confirm] == true
80
+
81
+ validate_native_yubikey_options(yubikey: yubikey)
82
+ opts
83
+ end
84
+
85
+ private_class_method def self.validate_native_yubikey_options(opts = {})
86
+ selector = opts[:yubikey]
87
+ allowed = %i[account confirm serial reader device_path connection timeout password secret_provider]
88
+ raise ArgumentError, 'Unknown yubikey OATH selector option' unless (selector.keys - allowed).empty?
89
+
90
+ serial = selector[:serial]
91
+ raise ArgumentError, 'yubikey serial must be an exact decimal String' unless serial.nil? || (serial.is_a?(String) && serial.valid_encoding? && serial.match?(/\A[0-9]+\z/))
92
+
93
+ timeout = selector[:timeout]
94
+ raise ArgumentError, 'yubikey timeout must be finite positive seconds' unless timeout.nil? || ((timeout.is_a?(Integer) || timeout.is_a?(Float)) && timeout.finite? && timeout.positive?)
95
+
96
+ %i[reader device_path].each do |field|
97
+ name = selector[field]
98
+ raise ArgumentError, "yubikey #{field} must be a nonempty String without control characters" unless name.nil? || (name.is_a?(String) && name.valid_encoding? && !name.empty? && !name.match?(/[[:cntrl:]]/))
99
+ end
100
+ raise ArgumentError, 'yubikey serial and reader selectors are mutually exclusive' if !serial.nil? && !selector[:reader].nil?
101
+ raise ArgumentError, 'yubikey reader and device_path selectors are mutually exclusive' if !selector[:reader].nil? && !selector[:device_path].nil?
102
+
103
+ connection = selector[:connection]
104
+ raise ArgumentError, 'yubikey connection must respond to exchange' unless connection.nil? || connection.respond_to?(:exchange)
105
+
106
+ password = selector[:password]
107
+ raise ArgumentError, 'yubikey password must be a nonempty in-memory String' unless password.nil? || (password.is_a?(String) && !password.empty?)
108
+
109
+ provider = selector[:secret_provider]
110
+ raise ArgumentError, 'yubikey secret_provider must respond to call' unless provider.nil? || provider.respond_to?(:call)
111
+
112
+ selector
113
+ end
114
+
57
115
  # Author(s):: 0day Inc. <support@0dayinc.com>
58
116
 
59
117
  public_class_method def self.authors
@@ -74,9 +132,21 @@ module PWN
74
132
  prompt: 'optional - string to display at prompt (Default: Password)'
75
133
  )
76
134
 
77
- # Run mfa and return its result
135
+ # Return a manual MFA token or exact-account YubiKey OATH digits without logging them.
78
136
  #{self}.mfa(
79
- prompt: 'optional - string to display at prompt (Default: MFA Token)'
137
+ prompt: 'optional - string to display at the manual prompt (Default: MFA Token)',
138
+ yubikey: 'optional - Hash selecting an exact OATH account (required :account and literal :confirm true); optional :serial exact decimal String, :reader or :device_path nonempty control-free String, :connection native exchange object, :timeout finite positive seconds (default 30), in-memory :password or callable :secret_provider; serial/reader and reader/device_path are mutually exclusive; no OTP slot; explicit mfa/mfa_token values conflict'
139
+ )
140
+ # Example yubikey: { account: 'EXACT_OATH_NAME', confirm: true, serial: '123456', timeout: 30 }
141
+ # Explicit consent is required: HOTP advances counters and protected OATH accounts may require authentication. No automatic repair or deletion occurs.
142
+ # The account name is passed unchanged. Device failures and cancellation propagate; no manual fallback.
143
+ # OATH credentials are distinct from OTP slots, SMS codes, and FIDO2/WebAuthn security keys.
144
+
145
+ # Validate MFA source selection before login without prompting or accessing a device; return opts.
146
+ #{self}.validate_mfa_options(
147
+ yubikey: 'optional - Hash selecting an exact OATH account (required :account and literal :confirm true); optional :serial exact decimal String, :reader or :device_path nonempty control-free String, :connection native exchange object, :timeout finite positive seconds (default 30), in-memory :password or callable :secret_provider; serial/reader and reader/device_path are mutually exclusive; no OTP slot; explicit mfa/mfa_token values conflict',
148
+ mfa: 'optional - browser MFA flag or explicit token string; a nonblank token conflicts with YubiKey',
149
+ mfa_token: 'optional - explicit browser MFA token; a nonblank value conflicts with YubiKey'
80
150
  )
81
151
 
82
152
  # Print the AUTHOR(S) string for this module.