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
data/lib/pwn/config.rb CHANGED
@@ -364,13 +364,20 @@ module PWN
364
364
 
365
365
  # Supported Method Parameters::
366
366
  # env = PWN::Config.default_env(
367
- # pwn_env_path: 'optional - Path to pwn.yaml file. Defaults to ~/.pwn/pwn.yaml'
367
+ # pwn_env_path: 'optional - Path to pwn.yaml file. Defaults to ~/.pwn/pwn.yaml',
368
+ # pwn_dec_path: 'optional - Legacy decryptor path; defaults to the vault path plus .decryptor',
369
+ # yubikey: 'optional - Explicit hardware opt-in with slot, serial, reader, device_path, connection, timeout selector'
368
370
  # )
369
371
  public_class_method def self.default_env(opts = {})
370
- pwn_env_path = opts[:pwn_env_path]
371
- pwn_dec_path = "#{pwn_env_path}.decryptor"
372
-
373
- puts "
372
+ pwn_env_path = opts[:pwn_env_path] || File.join(Dir.home, '.pwn', 'pwn.yaml')
373
+ pwn_dec_path = opts[:pwn_dec_path] || "#{pwn_env_path}.decryptor"
374
+ yubikey = opts[:yubikey]
375
+ raise ArgumentError, 'yubikey must be a selector Hash' unless yubikey.nil? || yubikey.is_a?(Hash)
376
+
377
+ if yubikey
378
+ puts "[*] Writing YubiKey-protected PWN::Env to #{pwn_env_path}; no software decryptor is created."
379
+ else
380
+ puts "
374
381
  [*] NOTICE:
375
382
  1. Writing minimal PWN::Env to:
376
383
  #{pwn_env_path}
@@ -381,15 +388,22 @@ module PWN
381
388
  4. For optimal security, it's recommended to move:
382
389
  #{pwn_dec_path}
383
390
  to a secure location and use the --pwn-dec parameter for PWN drivers.
384
- "
391
+ "
392
+ end
385
393
  env = env_template
386
394
 
387
- # Remove beginning colon from key names
388
-
389
- yaml_env = YAML.dump(env).gsub(/^(\s*):/, '\1')
390
- File.write(pwn_env_path, yaml_env)
391
- # Change file permission to 600
392
- File.chmod(0o600, pwn_env_path)
395
+ # YAML tags preserve symbol keys without rewriting multiline scalar bytes.
396
+ yaml_env = YAML.dump(env)
397
+ FileUtils.mkdir_p(File.dirname(pwn_env_path), mode: 0o700)
398
+ if yubikey
399
+ encrypted = PWN::Plugins::Vault.encrypt_data(data: yaml_env, yubikey: yubikey)
400
+ File.open(pwn_env_path, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |file| file.write(encrypted) }
401
+ else
402
+ File.write(pwn_env_path, yaml_env)
403
+ # Change file permission to 600
404
+ File.chmod(0o600, pwn_env_path)
405
+ PWN::Plugins::Vault.create(file: pwn_env_path, decryptor_file: pwn_dec_path)
406
+ end
393
407
 
394
408
  # Ensure skills dir for pwn-ai agent (in parent of pwn_env_path)
395
409
  pwn_env_root = File.dirname(pwn_env_path)
@@ -401,11 +415,6 @@ module PWN
401
415
  pwn_dec_path: pwn_dec_path
402
416
  }
403
417
 
404
- PWN::Plugins::Vault.create(
405
- file: pwn_env_path,
406
- decryptor_file: pwn_dec_path
407
- )
408
-
409
418
  Pry.config.refresh_pwn_env = false if defined?(Pry)
410
419
  env[:pwn_skills_path] = pwn_skills_path
411
420
  PWN::Config.install_default_skills(pwn_skills_path: pwn_skills_path)
@@ -430,6 +439,7 @@ module PWN
430
439
  # )
431
440
  public_class_method def self.redact_sensitive_artifacts(opts = {})
432
441
  config = opts[:config] ||= PWN::Env
442
+ return config.map { |value| value.is_a?(Hash) || value.is_a?(Array) ? redact_sensitive_artifacts(config: value) : value } if config.is_a?(Array)
433
443
 
434
444
  sensitive_keys = %i[
435
445
  admin_key
@@ -445,14 +455,38 @@ module PWN
445
455
  refresh_token
446
456
  secret_key
447
457
  token
458
+ pin
459
+ new_pin
460
+ admin_pin
461
+ puk
462
+ new_puk
463
+ management_key
464
+ new_management_key
465
+ management_password
466
+ access_code
467
+ new_access_code
468
+ oath_secret
469
+ oath_password
470
+ unlock_code
471
+ unlock_password
472
+ lock_code
473
+ new_lock_code
474
+ reset_code
475
+ scp
476
+ scp_key
477
+ scp_keys
478
+ scp03_key
479
+ scp11_key
480
+ static_password
481
+ hmac_secret
448
482
  ]
449
483
 
450
484
  # Transform values at the current level: redact sensitive keys
451
485
  config.transform_values.with_index do |v, k|
452
- if sensitive_keys.include?(config.keys[k])
486
+ if sensitive_keys.include?(config.keys[k].to_s.downcase.tr('-', '_').to_sym)
453
487
  '>>> REDACTED >>> USE `pwn-vault` FOR ADMINISTRATION <<< REDACTED <<<'
454
488
  else
455
- v.is_a?(Hash) ? redact_sensitive_artifacts(config: v) : v
489
+ v.is_a?(Hash) || v.is_a?(Array) ? redact_sensitive_artifacts(config: v) : v
456
490
  end
457
491
  end
458
492
  rescue StandardError => e
@@ -477,7 +511,8 @@ module PWN
477
511
  # Supported Method Parameters::
478
512
  # PWN::Config.refresh_env(
479
513
  # pwn_env_path: 'optional - Path to pwn.yaml file. Defaults to ~/.pwn/pwn.yaml',
480
- # pwn_dec_path: 'optional - Path to pwn.yaml.decryptor file. Defaults to ~/.pwn/pwn.yaml.decryptor'
514
+ # pwn_dec_path: 'optional - Legacy decryptor path; not needed for hardware vaults',
515
+ # yubikey: 'optional - Hardware selector override or explicit opt-in for a new vault'
481
516
  # )
482
517
 
483
518
  # Deep-fill missing Hash keys from defaults into target (mutates target).
@@ -541,27 +576,20 @@ module PWN
541
576
  pwn_skills_path = File.join(pwn_env_root, 'skills')
542
577
  FileUtils.mkdir_p(pwn_skills_path)
543
578
 
544
- return default_env(pwn_env_path: pwn_env_path) unless File.exist?(pwn_env_path)
579
+ return default_env(pwn_env_path: pwn_env_path, pwn_dec_path: opts[:pwn_dec_path], yubikey: opts[:yubikey]) unless File.exist?(pwn_env_path)
545
580
 
546
581
  is_encrypted = PWN::Plugins::Vault.file_encrypted?(file: pwn_env_path)
547
582
  raise "PWN Environment (#{pwn_env_path}) is not encrypted! Use PWN::Vault.create(file: '#{pwn_env_path}', decryptor_file: '#{pwn_env_path}.decryptor') to encrypt it." unless is_encrypted
548
583
 
549
584
  pwn_dec_path = opts[:pwn_dec_path] ||= "#{pwn_env_path}.decryptor"
550
- raise "PWN Decryptor (#{pwn_dec_path}) does not exist!" unless File.exist?(pwn_dec_path)
551
-
552
- pwn_decryptor = YAML.load_file(pwn_dec_path, symbolize_names: true)
553
-
554
- key = opts[:key] ||= pwn_decryptor[:key] ||= ENV.fetch('PWN_DECRYPTOR_KEY')
555
- key = PWN::Plugins::AuthenticationHelper.mask_password(prompt: 'Decryption Key') if key.nil?
556
-
557
- iv = opts[:iv] ||= pwn_decryptor[:iv] ||= ENV.fetch('PWN_DECRYPTOR_IV')
558
- iv = PWN::Plugins::AuthenticationHelper.mask_password(prompt: 'Decryption IV') if iv.nil?
559
-
560
- env = PWN::Plugins::Vault.dump(
585
+ access = PWN::Plugins::Vault.access_options(
561
586
  file: pwn_env_path,
562
- key: key,
563
- iv: iv
587
+ decryptor_file: pwn_dec_path,
588
+ key: opts[:key],
589
+ iv: opts[:iv],
590
+ yubikey: opts[:yubikey]
564
591
  )
592
+ env = PWN::Plugins::Vault.dump(access.merge(file: pwn_env_path))
565
593
 
566
594
  valid_ai_engines = PWN::AI.help.reject { |e| e.downcase == :agent }.map(&:downcase)
567
595
 
@@ -1142,7 +1170,9 @@ module PWN
1142
1170
 
1143
1171
  # Run default env and return its result
1144
1172
  #{self}.default_env(
1145
- pwn_env_path: 'optional - Path to pwn.yaml file. Defaults to ~/.pwn/pwn.yaml'
1173
+ pwn_env_path: 'optional - Path to pwn.yaml file. Defaults to ~/.pwn/pwn.yaml',
1174
+ pwn_dec_path: 'optional - Legacy decryptor path; defaults to the vault path plus .decryptor',
1175
+ yubikey: 'optional - Explicit hardware opt-in with slot, serial, reader, device_path, connection, timeout selector; no sidecar'
1146
1176
  )
1147
1177
 
1148
1178
  # Run redact sensitive artifacts and return its result
@@ -1155,10 +1185,11 @@ module PWN
1155
1185
 
1156
1186
  # Run refresh env and return its result
1157
1187
  #{self}.refresh_env(
1158
- pwn_env_path: 'required - pwn env path value consumed by #refresh_env',
1159
- pwn_dec_path: 'required - pwn dec path value consumed by #refresh_env',
1160
- key: 'optional - key value consumed by #refresh_env',
1161
- iv: 'optional - iv value consumed by #refresh_env'
1188
+ pwn_env_path: 'optional - Configuration vault path; defaults to ~/.pwn/pwn.yaml',
1189
+ pwn_dec_path: 'optional - Legacy decryptor path; not needed for hardware vaults',
1190
+ key: 'optional - Legacy vault key override',
1191
+ iv: 'optional - Legacy vault IV override',
1192
+ yubikey: 'optional - Hardware selector with slot, serial, reader, device_path, connection, timeout; opts in for new vaults'
1162
1193
  )
1163
1194
 
1164
1195
  # Run pwn skills path and return its result
data/lib/pwn/cron.rb CHANGED
@@ -48,6 +48,8 @@ module PWN
48
48
  # enabled: true
49
49
  # )
50
50
  public_class_method def self.create(opts = {})
51
+ return with_registry_lock { create(opts) } unless registry_owned?
52
+
51
53
  jobs = load_jobs
52
54
  id = SecureRandom.hex(6)
53
55
  name = opts[:name] || "job-#{id}"
@@ -69,7 +71,7 @@ module PWN
69
71
 
70
72
  # Optionally install a crontab entry (user must have permission)
71
73
  install_crontab_entry(job: job) if opts[:install_crontab]
72
- sync_one_job(job: job) if cron_enabled? && !opts[:install_crontab]
74
+ sync_one_job(job: job) if cron_enabled? && !opts[:install_crontab] && opts.fetch(:sync_native, true)
73
75
 
74
76
  job
75
77
  end
@@ -83,6 +85,7 @@ module PWN
83
85
  jobs = load_jobs
84
86
  job = jobs[id] || jobs.values.find { |j| j[:name] == id || j[:id] == id }
85
87
  raise "Job #{id} not found" unless job
88
+ return { skipped: true, reason: :housekeeping_internal_only, status: 'skipped' } if native_execution? && housekeeping_job?(job: job)
86
89
 
87
90
  start = Time.now
88
91
  result = nil
@@ -133,20 +136,28 @@ module PWN
133
136
 
134
137
  job[:last_run] = Time.now.utc.iso8601
135
138
  job[:last_status] = status
136
- jobs[job[:id]] = job
137
- save_jobs(jobs: jobs)
139
+ with_registry_lock do
140
+ current = load_jobs
141
+ if current.key?(job[:id])
142
+ current[job[:id]][:last_run] = job[:last_run]
143
+ current[job[:id]][:last_status] = status
144
+ save_jobs(jobs: current)
145
+ end
146
+ end
138
147
 
139
148
  { job: job, result: result, duration: Time.now - start, status: status }
140
149
  end
141
150
 
142
151
  # Supported Method Parameters::
143
- # PWN::Cron.remove(id:)
144
- public_class_method def self.remove(opts = {}) # rubocop:disable Naming/PredicateMethod
152
+ # PWN::Cron.remove(id:, sync_native: false)
153
+ public_class_method def self.remove(opts = {})
154
+ return with_registry_lock { remove(opts) } unless registry_owned?
155
+
145
156
  id = opts[:id].to_s
146
157
  jobs = load_jobs
147
158
  gone = jobs.delete(id)
148
159
  save_jobs(jobs: jobs)
149
- remove_native_job(job: gone) if gone
160
+ remove_native_job(job: gone) if gone && opts.fetch(:sync_native, true)
150
161
  true
151
162
  end
152
163
 
@@ -181,16 +192,48 @@ module PWN
181
192
  # and break jobs[id] / jobs.delete(id) / toggle lookups).
182
193
  raw.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
183
194
  rescue StandardError => e
184
- warn("[PWN::Cron] load_jobs failed: #{e.class}: #{e.message}")
185
- {}
195
+ raise IOError, "Cron registry unreadable (#{e.class}); refusing an empty replacement"
196
+ end
197
+
198
+ # A permanent inode coordinates registry writers, never job execution.
199
+ private_class_method def self.with_registry_lock
200
+ return yield if registry_owned?
201
+
202
+ owner = Thread.current[:pwn_cron_registry_owner]
203
+ File.open(File.join(cron_dir, '.jobs.write.lock'), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |file|
204
+ file.flock(File::LOCK_EX)
205
+ begin
206
+ Thread.current[:pwn_cron_registry_owner] = [Process.pid, Thread.current, jobs_file]
207
+ yield
208
+ ensure
209
+ Thread.current[:pwn_cron_registry_owner] = owner
210
+ end
211
+ end
212
+ end
213
+
214
+ private_class_method def self.registry_owned?
215
+ Thread.current[:pwn_cron_registry_owner] == [Process.pid, Thread.current, jobs_file]
186
216
  end
187
217
 
188
218
  private_class_method def self.save_jobs(opts = {})
189
219
  jobs = opts[:jobs] ||= {}
190
- File.write(jobs_file, YAML.dump(jobs))
220
+ temporary = "#{jobs_file}.#{SecureRandom.hex(8)}.tmp"
221
+ begin
222
+ File.open(temporary, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
223
+ file.write(YAML.dump(jobs))
224
+ file.flush
225
+ file.fsync
226
+ end
227
+ File.rename(temporary, jobs_file)
228
+ File.open(cron_dir, File::RDONLY, &:fsync)
229
+ ensure
230
+ FileUtils.rm_f(temporary)
231
+ end
191
232
  end
192
233
 
193
234
  private_class_method def self.toggle(opts = {})
235
+ return with_registry_lock { toggle(opts) } unless registry_owned?
236
+
194
237
  id = opts[:id].to_s
195
238
  enabled = opts[:enabled]
196
239
  jobs = load_jobs
@@ -202,9 +245,29 @@ module PWN
202
245
  jobs[id]
203
246
  end
204
247
 
248
+ private_class_method def self.native_execution?
249
+ return true if ENV['PWN_CRON_NATIVE'] == '1'
250
+
251
+ # An interactive terminal may inherit INVOCATION_ID; only PWN scheduler
252
+ # cgroups identify legacy units that predate the explicit marker.
253
+ File.read('/proc/self/cgroup').match?(%r{/pwn-cron-(?:worker|j[^/]+)\.service(?:/|$)})
254
+ rescue Errno::ENOENT, Errno::EACCES
255
+ false
256
+ end
257
+
258
+ # Housekeeping is owned exclusively by the portable worker/manual commands.
259
+ private_class_method def self.housekeeping_job?(opts = {})
260
+ job = opts[:job]
261
+ return false unless job.is_a?(Hash)
262
+
263
+ %w[session_cleanup learning_consolidate_nightly pwn_stores_lean_nightly].include?(job[:name].to_s) ||
264
+ [job[:ruby], job[:script]].any? { |payload| payload.to_s.match?(/SessionCleanup|session_cleanup\.rb/) }
265
+ end
266
+
205
267
  private_class_method def self.sync_one_job(opts = {})
206
268
  job = opts[:job]
207
269
  return unless job.is_a?(Hash)
270
+ return if housekeeping_job?(job: job)
208
271
 
209
272
  if job[:enabled] && cron_enabled?
210
273
  install_native_job(job: job)
@@ -218,15 +281,11 @@ module PWN
218
281
 
219
282
  # Supported Method Parameters::
220
283
  # PWN::Cron.install_defaults
221
- # Idempotently seed the RL feedback-loop cron jobs so a fresh install
222
- # closes the loop by default (S1 practice + P3 offline_judge + W2 train dry-run + M1 consolidate).
223
- # Re-running is a no-op if jobs with these names already exist.
224
- # These are seeded to jobs.yml only — pass install_crontab: true to
225
- # PWN::Cron.create yourself if you also want a system crontab entry.
226
- # Hygiene jobs are enabled. Curriculum self-play / judge / LoRA are
227
- # seeded disabled: practice runs Loop.run (real tools) overnight;
228
- # offline_judge spends API tokens; train needs a GPU trainer.
229
- DEFAULT_JOBS = [
284
+ # Fresh installs seed one internal housekeeping coordinator. Existing
285
+ # schedules are preserved, not automatically merged or re-enabled.
286
+ # Curriculum templates below require explicit create; installation and
287
+ # migration never seed them, including after operator deletion.
288
+ CURRICULUM_JOBS = [
230
289
  {
231
290
  name: 'curriculum_practice_nightly',
232
291
  schedule: '0 3 * * *',
@@ -245,29 +304,24 @@ module PWN
245
304
  ruby: 'PWN::AI::Agent::Curriculum.offline_judge(since_hours: 24) if defined?(PWN::AI::Agent::Curriculum)',
246
305
  aliases: %w[offline_judge_nightly],
247
306
  enabled: false
248
- },
249
- {
250
- name: 'learning_consolidate_nightly',
251
- schedule: '0 5 * * *',
252
- ruby: 'PWN::AI::Agent::Learning.consolidate if defined?(PWN::AI::Agent::Learning)',
253
- enabled: true
254
- },
307
+ }
308
+ ].freeze
309
+
310
+ DEFAULT_JOBS = [
255
311
  {
256
312
  name: 'session_cleanup',
257
313
  schedule: '45 3 * * *',
258
- ruby: "require File.join(PWN::Cron.cron_dir, 'session_cleanup.rb'); PWN::SessionCleanup.maintain(dry_run: false)",
259
- enabled: true
260
- },
261
- {
262
- name: 'pwn_stores_lean_nightly',
263
- schedule: '15 5 * * *',
264
- ruby: 'PWN::AI::Agent::Learning.gc_stores! if defined?(PWN::AI::Agent::Learning)',
314
+ ruby: "require File.join(PWN::Cron.cron_dir, 'session_cleanup.rb'); PWN::SessionCleanup.coordinate(dry_run: false)",
265
315
  enabled: true
266
316
  }
267
317
  ].freeze
268
318
 
269
319
  public_class_method def self.install_defaults
320
+ return with_registry_lock { install_defaults } unless registry_owned?
321
+
270
322
  install_default_payloads
323
+ require File.expand_path('../../etc/default_cron/session_cleanup', __dir__)
324
+ PWN::SessionCleanup.install(root: File.dirname(cron_dir))
271
325
  jobs = load_jobs
272
326
  names = jobs.values.map { |j| j[:name].to_s }
273
327
  seeded = []
@@ -282,21 +336,13 @@ module PWN
282
336
  schedule: spec[:schedule],
283
337
  ruby: default_payload(spec: spec),
284
338
  delivery: 'log',
339
+ sync_native: false,
285
340
  enabled: spec.fetch(:enabled, true)
286
341
  )
287
342
  names << spec[:name].to_s
288
343
  end
289
344
 
290
- # P13 - disable the legacy alias when both occupy the 30 3 * * * slot.
291
- if names.include?('curriculum_offline_judge') && names.include?('offline_judge_nightly')
292
- begin
293
- dup = jobs.values.find { |j| j[:name].to_s == 'offline_judge_nightly' }
294
- disable(id: dup[:id]) if dup && dup[:enabled]
295
- rescue StandardError
296
- nil
297
- end
298
- end
299
-
345
+ PWN::SessionCleanup.request_background(root: File.dirname(cron_dir))
300
346
  seeded
301
347
  rescue StandardError => e
302
348
  warn("[PWN::Cron] install_defaults failed: #{e.class}: #{e.message}")
@@ -327,7 +373,9 @@ module PWN
327
373
 
328
374
  slot = Time.at(((Time.now.to_i / 60) + 1) * 60)
329
375
  slot += 60 until slot.hour == 3 && slot.min == 45
330
- "if Time.now.to_i < #{slot.to_i}; { deferred: true, deleted: 0 }; else; #{spec[:ruby]}; end"
376
+ payload = spec[:ruby]
377
+ payload = payload.sub('SessionCleanup.coordinate(', 'SessionCleanup.maintain(') if load_jobs.values.any? { |job| %w[learning_consolidate_nightly pwn_stores_lean_nightly].include?(job[:name]) }
378
+ "if Time.now.to_i < #{slot.to_i}; { deferred: true, deleted: 0 }; else; #{payload}; end"
331
379
  end
332
380
 
333
381
  # ------------------------------------------------------------------
@@ -410,6 +458,7 @@ module PWN
410
458
  load_jobs.each_with_object({}) do |(id, job), acc|
411
459
  next unless job.is_a?(Hash)
412
460
  next unless job[:enabled]
461
+ next if native_execution? && housekeeping_job?(job: job)
413
462
  next unless due?(schedule: job[:schedule], last_run: job[:last_run], now: now)
414
463
 
415
464
  acc[id] = job
@@ -533,7 +582,16 @@ module PWN
533
582
 
534
583
  # Single tick used by the loop AND by tests (no sleep).
535
584
  public_class_method def self.tick(opts = {})
536
- run_due(now: opts[:now] || Time.now)
585
+ results = run_due(now: opts[:now] || Time.now)
586
+ return results if native_execution?
587
+
588
+ require File.expand_path('../../etc/default_cron/session_cleanup', __dir__)
589
+ background = PWN::SessionCleanup.background_request(root: File.dirname(cron_dir)) do
590
+ job = load_jobs.values.find { |entry| entry[:name] == 'session_cleanup' && entry[:enabled] }
591
+ results.find { |entry| entry[:id] == job[:id] } || run(id: job[:id]).merge(id: job[:id]) if job
592
+ end
593
+ results << background if background && results.none? { |entry| entry[:id] == background[:id] }
594
+ results
537
595
  end
538
596
 
539
597
  # Blocking poll loop. `once: true` runs a single tick then returns
@@ -1064,6 +1122,7 @@ module PWN
1064
1122
 
1065
1123
  public_class_method def self.install_native_job(opts = {})
1066
1124
  job = opts[:job]
1125
+ return { skipped: true, reason: :housekeeping_internal_only } if housekeeping_job?(job: job)
1067
1126
  return nil unless job.is_a?(Hash) && job[:id]
1068
1127
 
1069
1128
  backend = (opts[:backend] || scheduler_backend).to_sym
@@ -1080,6 +1139,8 @@ module PWN
1080
1139
 
1081
1140
  public_class_method def self.remove_native_job(opts = {})
1082
1141
  job = opts[:job] || { id: opts[:id], name: opts[:name] }
1142
+ return { skipped: true, reason: :housekeeping_internal_only } if housekeeping_job?(job: job)
1143
+
1083
1144
  backend = (opts[:backend] || scheduler_backend).to_sym
1084
1145
  apply = apply_native?(opts)
1085
1146
  case backend
@@ -1110,7 +1171,7 @@ module PWN
1110
1171
  public_class_method def self.install_crontab_worker(opts = {})
1111
1172
  apply = apply_native?(opts)
1112
1173
  marker = 'PWN::Cron.ensure_worker'
1113
- line = "@reboot #{ruby_eval_shell(snippet: 'require "pwn"; PWN::Cron.ensure_worker(restart: false)', log: worker_log)}"
1174
+ line = "@reboot #{ruby_eval_shell(snippet: 'ENV["PWN_CRON_NATIVE"] = "1"; require "pwn"; PWN::Cron.ensure_worker(restart: false)', log: worker_log)}"
1114
1175
  preview = "# pwn-cron worker\n#{line}\n"
1115
1176
  write_native_preview(name: 'worker.crontab', body: preview)
1116
1177
  return { backend: :crontab, apply: false, line: line } unless apply && crontab_available?
@@ -1124,6 +1185,8 @@ module PWN
1124
1185
 
1125
1186
  public_class_method def self.install_crontab_job(opts = {})
1126
1187
  job = opts[:job]
1188
+ return { skipped: true, reason: :housekeeping_internal_only } if housekeeping_job?(job: job)
1189
+
1127
1190
  apply = apply_native?(opts)
1128
1191
  snippet = "require \"pwn\"; PWN::Cron.run(id: #{job[:id].to_s.inspect})"
1129
1192
  line = "#{job[:schedule]} #{ruby_eval_shell(snippet: snippet, log: File.join(cron_dir, 'cron.log'))}"
@@ -1165,7 +1228,7 @@ module PWN
1165
1228
  public_class_method def self.install_systemd_user_worker(opts = {})
1166
1229
  apply = apply_native?(opts)
1167
1230
  exec_start = ruby_eval_shell(
1168
- snippet: 'require "pwn"; PWN::Cron.start_worker(restart: false, foreground: true)'
1231
+ snippet: 'ENV["PWN_CRON_NATIVE"] = "1"; require "pwn"; PWN::Cron.start_worker(restart: false, foreground: true)'
1169
1232
  )
1170
1233
  body = <<~UNIT
1171
1234
  [Unit]
@@ -1194,6 +1257,8 @@ module PWN
1194
1257
 
1195
1258
  public_class_method def self.install_systemd_user_job(opts = {})
1196
1259
  job = opts[:job]
1260
+ return { skipped: true, reason: :housekeeping_internal_only } if housekeeping_job?(job: job)
1261
+
1197
1262
  apply = apply_native?(opts)
1198
1263
  cal = cron_to_calendar(schedule: job[:schedule])
1199
1264
  oncal = systemd_on_calendar(calendar: cal)
@@ -1272,7 +1337,7 @@ module PWN
1272
1337
  label = LAUNCHD_WORKER_LABEL
1273
1338
  plist = launchd_plist(
1274
1339
  label: label,
1275
- argv: ruby_eval_argv(snippet: 'require "pwn"; PWN::Cron.start_worker(restart: false, foreground: true)'),
1340
+ argv: ruby_eval_argv(snippet: 'ENV["PWN_CRON_NATIVE"] = "1"; require "pwn"; PWN::Cron.start_worker(restart: false, foreground: true)'),
1276
1341
  keepalive: true,
1277
1342
  run_at_load: true
1278
1343
  )
@@ -1283,6 +1348,8 @@ module PWN
1283
1348
 
1284
1349
  public_class_method def self.install_launchd_job(opts = {})
1285
1350
  job = opts[:job]
1351
+ return { skipped: true, reason: :housekeeping_internal_only } if housekeeping_job?(job: job)
1352
+
1286
1353
  apply = apply_native?(opts)
1287
1354
  ints = launchd_calendar_interval(schedule: job[:schedule])
1288
1355
  return { backend: :launchd, id: job[:id], skipped: true, reason: :no_calendar } unless ints
@@ -1325,7 +1392,7 @@ module PWN
1325
1392
 
1326
1393
  public_class_method def self.install_schtasks_worker(opts = {})
1327
1394
  apply = apply_native?(opts)
1328
- tr = ruby_eval_shell(snippet: 'require "pwn"; PWN::Cron.ensure_worker(restart: false)')
1395
+ tr = ruby_eval_shell(snippet: 'ENV["PWN_CRON_NATIVE"] = "1"; require "pwn"; PWN::Cron.ensure_worker(restart: false)')
1329
1396
  argv = ['schtasks', '/Create', '/F', '/TN', SCHTASKS_WORKER_NAME, '/SC', 'ONLOGON', '/RL', 'LIMITED', '/TR', tr]
1330
1397
  write_native_preview(name: 'worker.schtasks.txt', body: argv.inspect)
1331
1398
  run_argv(argv: argv) if apply && schtasks_available?
@@ -1334,6 +1401,8 @@ module PWN
1334
1401
 
1335
1402
  public_class_method def self.install_schtasks_job(opts = {})
1336
1403
  job = opts[:job]
1404
+ return { skipped: true, reason: :housekeeping_internal_only } if housekeeping_job?(job: job)
1405
+
1337
1406
  apply = apply_native?(opts)
1338
1407
  spec = schtasks_spec(schedule: job[:schedule])
1339
1408
  return { backend: :schtasks, id: job[:id], skipped: true, reason: :no_calendar } unless spec
@@ -1568,6 +1637,7 @@ module PWN
1568
1637
  script: 'optional - path to external script',
1569
1638
  delivery: 'optional - log|stdout (default log)',
1570
1639
  enabled: 'optional - enabled value consumed by #create',
1640
+ sync_native: 'optional - synchronize generic native jobs (default true); defaults are internal only',
1571
1641
  install_crontab: 'optional - install crontab value consumed by #create'
1572
1642
  )
1573
1643
 
@@ -1578,7 +1648,8 @@ module PWN
1578
1648
 
1579
1649
  # Run remove and return its result
1580
1650
  #{self}.remove(
1581
- id: 'optional - id value consumed by #remove'
1651
+ id: 'optional - id value consumed by #remove',
1652
+ sync_native: 'optional - false deletes only the schedule, preserving native schedulers and evidence'
1582
1653
  )
1583
1654
 
1584
1655
  # Run enable and return its result