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.
- checksums.yaml +4 -4
- data/.ruby-version +1 -1
- data/Gemfile +8 -8
- data/README.md +37 -0
- data/Rakefile +19 -1
- data/bin/pwn +2 -1
- data/bin/pwn_setup +1 -1
- data/bin/pwn_web_cache_deception +35 -7
- data/build_gem.sh +1 -1
- data/documentation/AI-Integration.md +7 -5
- data/documentation/Agent-Tool-Registry.md +2 -2
- data/documentation/Backup-Retention.md +82 -0
- data/documentation/Configuration.md +10 -6
- data/documentation/Contributing.md +1 -1
- data/documentation/Cron.md +64 -35
- data/documentation/Diagrams.md +2 -2
- data/documentation/General-PWN-Usage.md +4 -3
- data/documentation/Home.md +6 -5
- data/documentation/How-PWN-Works.md +7 -6
- data/documentation/Installation.md +254 -10
- data/documentation/Mission-Cancellation.md +134 -0
- data/documentation/Operability.md +17 -0
- data/documentation/Persistence.md +8 -6
- data/documentation/Plugin-Degradation.md +12 -0
- data/documentation/Plugins.md +3 -2
- data/documentation/Reinforcement-Learning.md +5 -6
- data/documentation/Runtime-Source-Protection.md +162 -0
- data/documentation/Transparent-Browser.md +11 -0
- data/documentation/Troubleshooting.md +3 -2
- data/documentation/What-is-PWN.md +2 -2
- data/documentation/YubiKey-Vaults.md +183 -0
- data/documentation/YubiKey.md +339 -0
- data/documentation/diagrams/cron-scheduling.svg +130 -128
- data/documentation/diagrams/dot/cron-scheduling.dot +5 -5
- data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
- data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
- data/documentation/diagrams/persistence-filesystem.svg +172 -142
- data/documentation/diagrams/plugin-ecosystem.svg +64 -64
- data/documentation/pwn-ai-Agent.md +10 -4
- data/etc/default_cron/session_cleanup.rb +1245 -22
- data/etc/default_skills/pwn/ai/agent/mission_namespace/SKILL.md +21 -0
- data/etc/default_skills/pwn/ai/agent/tools/browser_act/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +27 -26
- data/etc/default_skills/pwn/ai/agent/tools/recon_run/SKILL.md +25 -15
- data/etc/default_skills/pwn/ai/agent/tools/workspace_tool/SKILL.md +24 -0
- data/etc/default_skills/pwn/ai/server_tools/SKILL.md +25 -0
- data/etc/default_skills/pwn/backups/SKILL.md +54 -0
- data/etc/default_skills/pwn/plugins/recon/SKILL.md +12 -1
- data/etc/default_skills/pwn/plugins/yubikey/SKILL.md +157 -0
- data/etc/default_skills/pwn/plugins/yubikey/fido/SKILL.md +189 -0
- data/etc/default_skills/pwn/plugins/yubikey/hsmauth/SKILL.md +83 -0
- data/etc/default_skills/pwn/plugins/yubikey/management/SKILL.md +80 -0
- data/etc/default_skills/pwn/plugins/yubikey/oath/SKILL.md +115 -0
- data/etc/default_skills/pwn/plugins/yubikey/openpgp/SKILL.md +144 -0
- data/etc/default_skills/pwn/plugins/yubikey/otp/SKILL.md +97 -0
- data/etc/default_skills/pwn/plugins/yubikey/piv/SKILL.md +110 -0
- data/etc/default_skills/pwn/plugins/yubikey/protocol/SKILL.md +21 -0
- data/etc/default_skills/pwn/plugins/yubikey/security_domain/SKILL.md +75 -0
- data/etc/default_skills/pwn/plugins/yubikey/security_domain/scp/SKILL.md +46 -0
- data/etc/default_skills/pwn/plugins/yubikey/transport/SKILL.md +98 -0
- data/etc/default_skills/pwn/redaction/SKILL.md +13 -1
- data/lib/pwn/ai/agent/dispatch.rb +26 -15
- data/lib/pwn/ai/agent/extrospection.rb +6 -7
- data/lib/pwn/ai/agent/learning.rb +62 -25
- data/lib/pwn/ai/agent/loop.rb +17 -1
- data/lib/pwn/ai/agent/mission_namespace.rb +331 -0
- data/lib/pwn/ai/agent/policy.rb +5 -0
- data/lib/pwn/ai/agent/policy_evaluation.rb +8 -8
- data/lib/pwn/ai/agent/prompt_builder.rb +13 -2
- data/lib/pwn/ai/agent/request_runtime.rb +122 -18
- data/lib/pwn/ai/agent/reward.rb +5 -3
- data/lib/pwn/ai/agent/skill_consolidation.rb +3 -2
- data/lib/pwn/ai/agent/skill_review.rb +9 -5
- data/lib/pwn/ai/agent/swarm.rb +35 -16
- data/lib/pwn/ai/agent/tools/browser_act.rb +32 -0
- data/lib/pwn/ai/agent/tools/loot_query.rb +11 -1
- data/lib/pwn/ai/agent/tools/recon_run.rb +11 -1
- data/lib/pwn/ai/agent/tools/workspace_tool.rb +29 -0
- data/lib/pwn/ai/agent/turn_finalizer.rb +6 -2
- data/lib/pwn/ai/agent.rb +1 -0
- data/lib/pwn/ai/anthropic.rb +8 -33
- data/lib/pwn/ai/cli.rb +34 -5
- data/lib/pwn/ai/gemini.rb +1 -0
- data/lib/pwn/ai/grok.rb +39 -28
- data/lib/pwn/ai/ollama.rb +4 -2
- data/lib/pwn/ai/open_ai.rb +11 -32
- data/lib/pwn/ai/open_web_ui.rb +62 -4
- data/lib/pwn/ai/router.rb +53 -3
- data/lib/pwn/ai/server_tools.rb +231 -0
- data/lib/pwn/ai.rb +1 -0
- data/lib/pwn/backups.rb +347 -0
- data/lib/pwn/config.rb +70 -39
- data/lib/pwn/cron.rb +120 -49
- data/lib/pwn/migrate.rb +150 -68
- data/lib/pwn/module_skills.rb +7 -0
- data/lib/pwn/plugins/ai_sandbox.rb +538 -1
- data/lib/pwn/plugins/authentication_helper.rb +74 -4
- data/lib/pwn/plugins/basic_auth.rb +2 -1
- data/lib/pwn/plugins/binary_parser.rb +5 -4
- data/lib/pwn/plugins/ghidra.rb +4 -3
- data/lib/pwn/plugins/google_workspace.rb +4 -21
- data/lib/pwn/plugins/jobs.rb +1 -1
- data/lib/pwn/plugins/log.rb +21 -3
- data/lib/pwn/plugins/preflight_checker.rb +34 -9
- data/lib/pwn/plugins/recon.rb +4 -1
- data/lib/pwn/plugins/repl/ai.rb +305 -27
- data/lib/pwn/plugins/repl/ai_console.rb +273 -89
- data/lib/pwn/plugins/repl/ai_swarm.rb +181 -62
- data/lib/pwn/plugins/repl/mesh.rb +3 -16
- data/lib/pwn/plugins/repl/vault.rb +4 -15
- data/lib/pwn/plugins/repl.rb +1 -216
- data/lib/pwn/plugins/sandbox/driver.rb +4 -0
- data/lib/pwn/plugins/transparent_browser.rb +215 -0
- data/lib/pwn/plugins/vault.rb +412 -135
- data/lib/pwn/plugins/yubikey/fido.rb +1288 -0
- data/lib/pwn/plugins/yubikey/hsmauth.rb +439 -0
- data/lib/pwn/plugins/yubikey/management.rb +686 -0
- data/lib/pwn/plugins/yubikey/oath.rb +766 -0
- data/lib/pwn/plugins/yubikey/openpgp.rb +1063 -0
- data/lib/pwn/plugins/yubikey/otp.rb +897 -0
- data/lib/pwn/plugins/yubikey/piv.rb +1315 -0
- data/lib/pwn/plugins/yubikey/protocol.rb +341 -0
- data/lib/pwn/plugins/yubikey/security_domain/scp.rb +482 -0
- data/lib/pwn/plugins/yubikey/security_domain.rb +464 -0
- data/lib/pwn/plugins/yubikey/transport.rb +1017 -0
- data/lib/pwn/plugins/yubikey.rb +844 -0
- data/lib/pwn/plugins.rb +1 -0
- data/lib/pwn/redaction.rb +69 -12
- data/lib/pwn/session_trace.rb +4 -0
- data/lib/pwn/sessions.rb +58 -16
- data/lib/pwn/setup.rb +123 -41
- data/lib/pwn/version.rb +1 -1
- data/lib/pwn/www/app_cobalt_io.rb +16 -5
- data/lib/pwn/www/bug_crowd.rb +16 -5
- data/lib/pwn/www/coinbase_pro.rb +16 -5
- data/lib/pwn/www/github.rb +30 -14
- data/lib/pwn/www/paypal.rb +15 -4
- data/lib/pwn/www/synack.rb +16 -5
- data/lib/pwn/www/twitter.rb +17 -6
- data/lib/pwn.rb +1 -0
- data/scripts/build_gate.rb +58 -0
- data/spec/documentation/installation_md_spec.rb +1 -1
- data/spec/fixtures/yubikey/apdu_frames.json +162 -0
- data/spec/integration/authentication_helper_oath_spec.rb +117 -0
- data/spec/integration/build_gate_spec.rb +73 -0
- data/spec/integration/controller_spill_spec.rb +48 -0
- data/spec/integration/harness_process_spec.rb +97 -0
- data/spec/integration/mission_cancel_spec.rb +79 -0
- data/spec/integration/mission_curses_pty_spec.rb +44 -0
- data/spec/integration/persistence_roundtrip_spec.rb +1 -7
- data/spec/integration/readme_security_spec.rb +47 -0
- data/spec/integration/web_cache_deception_yubikey_spec.rb +159 -0
- data/spec/lib/pwn/ai/agent/dispatch_spec.rb +6 -3
- data/spec/lib/pwn/ai/agent/dispatch_vault_safety_spec.rb +86 -0
- data/spec/lib/pwn/ai/agent/injection_guard_spec.rb +2 -0
- data/spec/lib/pwn/ai/agent/learning_spec.rb +4 -4
- data/spec/lib/pwn/ai/agent/loop_artifacts_spec.rb +5 -0
- data/spec/lib/pwn/ai/agent/loop_spec.rb +104 -29
- data/spec/lib/pwn/ai/agent/mission_namespace_spec.rb +138 -0
- data/spec/lib/pwn/ai/agent/policy_spec.rb +27 -0
- data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +48 -0
- data/spec/lib/pwn/ai/agent/shell_search_effect_spec.rb +44 -0
- data/spec/lib/pwn/ai/agent/solve_spec.rb +6 -4
- data/spec/lib/pwn/ai/agent/tools/browser_act_spec.rb +44 -0
- data/spec/lib/pwn/ai/agent/tools/loot_query_yubikey_spec.rb +55 -0
- data/spec/lib/pwn/ai/agent/tools/recon_run_yubikey_spec.rb +43 -0
- data/spec/lib/pwn/ai/agent/tools/workspace_tool_spec.rb +20 -0
- data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +7 -0
- data/spec/lib/pwn/ai/anthropic_vault_enrollment_spec.rb +37 -0
- data/spec/lib/pwn/ai/cli_spec.rb +6 -9
- data/spec/lib/pwn/ai/oauth_vault_persistence_spec.rb +195 -0
- data/spec/lib/pwn/ai/open_ai_spec.rb +2 -2
- data/spec/lib/pwn/ai/open_web_ui_workspace_spec.rb +36 -0
- data/spec/lib/pwn/ai/router_spec.rb +31 -3
- data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
- data/spec/lib/pwn/backups_spec.rb +114 -0
- data/spec/lib/pwn/config_redaction_yubikey_spec.rb +28 -0
- data/spec/lib/pwn/config_yubikey_spec.rb +92 -0
- data/spec/lib/pwn/cron_spec.rb +113 -6
- data/spec/lib/pwn/migrate_spec.rb +108 -5
- data/spec/lib/pwn/migrate_yubikey_spec.rb +128 -0
- data/spec/lib/pwn/module_skills_spec.rb +8 -0
- data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +230 -0
- data/spec/lib/pwn/plugins/authentication_helper_native_yubikey_spec.rb +40 -0
- data/spec/lib/pwn/plugins/authentication_helper_yubikey_spec.rb +90 -0
- data/spec/lib/pwn/plugins/capability_broker/daemon_spec.rb +1 -1
- data/spec/lib/pwn/plugins/google_workspace_spec.rb +17 -35
- data/spec/lib/pwn/plugins/log_spec.rb +7 -0
- data/spec/lib/pwn/plugins/log_yubikey_spec.rb +47 -0
- data/spec/lib/pwn/plugins/preflight_checker_native_yubikey_spec.rb +19 -0
- data/spec/lib/pwn/plugins/preflight_checker_yubikey_spec.rb +55 -0
- data/spec/lib/pwn/plugins/recon_yubikey_spec.rb +43 -0
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +125 -78
- data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
- data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +30 -16
- data/spec/lib/pwn/plugins/repl/ai_vault_edit_yubikey_spec.rb +103 -0
- data/spec/lib/pwn/plugins/repl/ai_yubikey_spec.rb +48 -0
- data/spec/lib/pwn/plugins/repl/mesh_vault_persistence_spec.rb +149 -0
- data/spec/lib/pwn/plugins/repl/vault_yaml_serialization_spec.rb +11 -0
- data/spec/lib/pwn/plugins/repl/vault_yubikey_spec.rb +39 -0
- data/spec/lib/pwn/plugins/repl_spec.rb +56 -5
- data/spec/lib/pwn/plugins/transparent_browser_jev_spec.rb +46 -0
- data/spec/lib/pwn/plugins/vault_native_yubikey_spec.rb +29 -0
- data/spec/lib/pwn/plugins/vault_publication_safety_spec.rb +258 -0
- data/spec/lib/pwn/plugins/vault_store_safety_spec.rb +241 -0
- data/spec/lib/pwn/plugins/vault_yubikey_safety_spec.rb +168 -0
- data/spec/lib/pwn/plugins/vault_yubikey_spec.rb +128 -0
- data/spec/lib/pwn/plugins/vault_yubikey_vector_spec.rb +45 -0
- data/spec/lib/pwn/plugins/yubikey/fido_spec.rb +854 -0
- data/spec/lib/pwn/plugins/yubikey/hsmauth_spec.rb +235 -0
- data/spec/lib/pwn/plugins/yubikey/management_spec.rb +277 -0
- data/spec/lib/pwn/plugins/yubikey/oath_spec.rb +349 -0
- data/spec/lib/pwn/plugins/yubikey/openpgp_spec.rb +810 -0
- data/spec/lib/pwn/plugins/yubikey/otp_spec.rb +337 -0
- data/spec/lib/pwn/plugins/yubikey/piv_spec.rb +646 -0
- data/spec/lib/pwn/plugins/yubikey/protocol_secure_spec.rb +28 -0
- data/spec/lib/pwn/plugins/yubikey/protocol_spec.rb +149 -0
- data/spec/lib/pwn/plugins/yubikey/protocol_vectors_spec.rb +18 -0
- data/spec/lib/pwn/plugins/yubikey/security_domain/scp_spec.rb +256 -0
- data/spec/lib/pwn/plugins/yubikey/security_domain/support.rb +153 -0
- data/spec/lib/pwn/plugins/yubikey/security_domain_spec.rb +296 -0
- data/spec/lib/pwn/plugins/yubikey/transport_spec.rb +760 -0
- data/spec/lib/pwn/plugins/yubikey_security_spec.rb +260 -0
- data/spec/lib/pwn/plugins/yubikey_spec.rb +209 -0
- data/spec/lib/pwn/plugins/yubikey_terminal_spec.rb +147 -0
- data/spec/lib/pwn/redaction_spec.rb +13 -10
- data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
- data/spec/lib/pwn/session_cleanup_concurrency_spec.rb +201 -0
- data/spec/lib/pwn/session_cleanup_default_spec.rb +167 -2
- data/spec/lib/pwn/session_cleanup_legacy_spec.rb +101 -0
- data/spec/lib/pwn/session_cleanup_payload_spec.rb +335 -2
- data/spec/lib/pwn/session_cleanup_recovery_spec.rb +403 -0
- data/spec/lib/pwn/sessions_spec.rb +112 -0
- data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
- data/spec/lib/pwn/setup_spec.rb +3 -1
- data/spec/lib/pwn/setup_vault_yubikey_spec.rb +61 -0
- data/spec/lib/pwn/setup_yubikey_spec.rb +40 -0
- data/spec/lib/pwn/www/login_yubikey_spec.rb +273 -0
- data/spec/spec_helper.rb +15 -0
- data/spec/support/gate_failure_formatter.rb +14 -0
- data/spec/support/harness/cli_faults.rb +13 -9
- data/spec/support/harness/controller_spill.rb +26 -0
- data/spec/support/harness/controller_state.rb +54 -0
- data/spec/support/harness/evaluate.rb +12 -1
- data/spec/support/harness/experiments.rb +1 -1
- data/spec/support/harness/fake_provider.rb +22 -6
- data/spec/support/harness/fault_preload.rb +3 -1
- data/spec/support/harness/mission_console.rb +54 -0
- data/spec/support/harness/mission_curses_pty.rb +41 -0
- data/spec/support/harness/mission_dispatch.rb +30 -0
- data/spec/support/harness/mission_fixture_preload.rb +24 -0
- data/spec/support/harness/mission_job_lifetime.rb +25 -0
- data/spec/support/harness/mission_legacy.rb +25 -0
- data/spec/support/harness/mission_normal_exit.rb +34 -0
- data/spec/support/harness/mission_swarm.rb +23 -0
- data/spec/support/harness/mission_unsupported.rb +29 -0
- data/spec/support/harness/package_acceptance.rb +33 -5
- data/spec/support/harness/session_acceptance.rb +4 -4
- data/spec/support/harness/swarm_acceptance.rb +1 -1
- data/spec/support/harness/swarm_control.rb +44 -0
- data/spec/support/mission_namespace.rb +14 -0
- data/spec/support/mission_pipe_queue.rb +33 -0
- data/third_party/pwn_rdoc.jsonl +599 -15
- metadata +140 -17
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module PWN
|
|
6
|
+
module AI
|
|
7
|
+
# Provider-hosted tools, separate from PWN::AI::Agent::Registry.
|
|
8
|
+
#
|
|
9
|
+
# Open WebUI is the only engine here with a user-installed tool workspace.
|
|
10
|
+
# Ask Jev is one of those tools. The other engines either run a fixed set of
|
|
11
|
+
# hosted tools on their own API, or they have no server-side tool surface.
|
|
12
|
+
module ServerTools
|
|
13
|
+
OPENAI = {
|
|
14
|
+
'web_search' => { type: 'web_search' },
|
|
15
|
+
'code_interpreter' => { type: 'code_interpreter' }
|
|
16
|
+
}.freeze
|
|
17
|
+
ANTHROPIC = {
|
|
18
|
+
'web_search' => { type: 'web_search_20250305', name: 'web_search' },
|
|
19
|
+
'web_fetch' => { type: 'web_fetch_20250910', name: 'web_fetch' }
|
|
20
|
+
}.freeze
|
|
21
|
+
GEMINI = {
|
|
22
|
+
'google_search' => { googleSearch: {} }
|
|
23
|
+
}.freeze
|
|
24
|
+
GROK = {
|
|
25
|
+
'web_search' => { type: 'web_search' },
|
|
26
|
+
'x_search' => { type: 'x_search' },
|
|
27
|
+
'code_interpreter' => { type: 'code_interpreter' }
|
|
28
|
+
}.freeze
|
|
29
|
+
WIRE = { openai: OPENAI, anthropic: ANTHROPIC, gemini: GEMINI, grok: GROK }.freeze
|
|
30
|
+
|
|
31
|
+
# Supported Method Parameters::
|
|
32
|
+
# row = PWN::AI::ServerTools.catalog(
|
|
33
|
+
# engine: 'required - openai, anthropic, gemini, grok, ollama, or openwebui'
|
|
34
|
+
# )
|
|
35
|
+
public_class_method def self.catalog(opts = {})
|
|
36
|
+
engine = opts[:engine].to_s.downcase.to_sym
|
|
37
|
+
{
|
|
38
|
+
engine: engine,
|
|
39
|
+
workspace: engine == :openwebui,
|
|
40
|
+
mixable: mixable?(engine: engine, model: opts[:model]),
|
|
41
|
+
tools: engine == :openwebui ? [] : Array(WIRE[engine]).map { |name, _wire| name }
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Supported Method Parameters::
|
|
46
|
+
# rows = PWN::AI::ServerTools.list(
|
|
47
|
+
# engine: 'required - provider name',
|
|
48
|
+
# refresh: 'optional - true re-reads the Open WebUI workspace tool list',
|
|
49
|
+
# model: 'optional - used to decide whether OpenAI hosted tools share the request'
|
|
50
|
+
# )
|
|
51
|
+
public_class_method def self.list(opts = {})
|
|
52
|
+
engine = opts[:engine].to_s.downcase.to_sym
|
|
53
|
+
return PWN::AI::OpenWebUI.workspace_tools(refresh: opts[:refresh]) if engine == :openwebui
|
|
54
|
+
|
|
55
|
+
catalog(engine: engine, model: opts[:model])[:tools]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Supported Method Parameters::
|
|
59
|
+
# objects = PWN::AI::ServerTools.wire(
|
|
60
|
+
# engine: 'required - provider name',
|
|
61
|
+
# names: 'required - hosted tool names such as web_search or google_search'
|
|
62
|
+
# )
|
|
63
|
+
public_class_method def self.wire(opts = {})
|
|
64
|
+
engine = opts[:engine].to_s.downcase.to_sym
|
|
65
|
+
table = WIRE[engine] || {}
|
|
66
|
+
Array(opts[:names]).map do |name|
|
|
67
|
+
row = table[name.to_s]
|
|
68
|
+
raise ArgumentError, "#{engine} has no hosted tool #{name}" unless row
|
|
69
|
+
|
|
70
|
+
row.dup
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Supported Method Parameters::
|
|
75
|
+
# same = PWN::AI::ServerTools.mixable?(
|
|
76
|
+
# engine: 'required - provider name',
|
|
77
|
+
# model: 'optional - OpenAI model id; hosted tools share only a Responses request'
|
|
78
|
+
# )
|
|
79
|
+
public_class_method def self.mixable?(opts = {})
|
|
80
|
+
engine = opts[:engine].to_s.downcase.to_sym
|
|
81
|
+
return false unless %i[anthropic gemini openai].include?(engine)
|
|
82
|
+
return true unless engine == :openai
|
|
83
|
+
|
|
84
|
+
PWN::AI::OpenAI.api_endpoint(model: opts[:model]) == 'responses'
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Supported Method Parameters::
|
|
88
|
+
# body = PWN::AI::ServerTools.attach(
|
|
89
|
+
# engine: 'required - provider name',
|
|
90
|
+
# http_body: 'required - provider request body',
|
|
91
|
+
# request: 'required - original chat options, including server_tools'
|
|
92
|
+
# )
|
|
93
|
+
public_class_method def self.attach(opts = {})
|
|
94
|
+
body = opts[:http_body]
|
|
95
|
+
names = selected(request: opts[:request])
|
|
96
|
+
return body if names.empty?
|
|
97
|
+
|
|
98
|
+
engine = opts[:engine].to_s.downcase.to_sym
|
|
99
|
+
hosted = wire(engine: engine, names: names)
|
|
100
|
+
case engine
|
|
101
|
+
when :anthropic, :gemini, :openai
|
|
102
|
+
raise ArgumentError, 'OpenAI hosted tools require the Responses API' if engine == :openai && !body.key?(:input)
|
|
103
|
+
|
|
104
|
+
body[:tools] = Array(body[:tools]) + hosted
|
|
105
|
+
else
|
|
106
|
+
raise ArgumentError, "#{engine} cannot mix hosted tools into this request"
|
|
107
|
+
end
|
|
108
|
+
body
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Supported Method Parameters::
|
|
112
|
+
# result = PWN::AI::ServerTools.call(
|
|
113
|
+
# name: 'required - workspace tool name or hosted tool name',
|
|
114
|
+
# request: 'required - question or state to send with that tool',
|
|
115
|
+
# engine: 'optional - provider name; Ask Jev uses Open WebUI when that provider is configured',
|
|
116
|
+
# model: 'optional - overrides the provider model'
|
|
117
|
+
# )
|
|
118
|
+
public_class_method def self.call(opts = {})
|
|
119
|
+
name = opts[:name].to_s.strip
|
|
120
|
+
request = opts[:request].to_s
|
|
121
|
+
raise ArgumentError, 'request is required' if request.strip.empty?
|
|
122
|
+
|
|
123
|
+
engine = route(name: name, engine: opts[:engine])
|
|
124
|
+
return { available: false, engine: engine, reason: 'Ollama has no server-side tool workspace' } if engine == :ollama
|
|
125
|
+
return openwebui_call(name: name, request: request, model: opts[:model]) if engine == :openwebui
|
|
126
|
+
|
|
127
|
+
mod = Object.const_get(PWN::AI::Agent::Reflect::ENGINE_MODS.fetch(engine))
|
|
128
|
+
response = mod.chat_with_tools(
|
|
129
|
+
messages: [{ role: 'user', content: request }],
|
|
130
|
+
server_tools: [name],
|
|
131
|
+
model: opts[:model],
|
|
132
|
+
spinner: false
|
|
133
|
+
)
|
|
134
|
+
{ available: true, engine: engine, name: name, content: answer_text(response: response) }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
public_class_method def self.authors
|
|
138
|
+
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
public_class_method def self.help
|
|
142
|
+
puts "USAGE:
|
|
143
|
+
# Describe whether an engine has a workspace or only fixed hosted tools.
|
|
144
|
+
#{self}.catalog(
|
|
145
|
+
engine: 'required - openai, anthropic, gemini, grok, ollama, or openwebui',
|
|
146
|
+
model: 'optional - OpenAI model id used only to report whether hosted tools can share the request'
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# List hosted tool names, or the installed Open WebUI workspace tools.
|
|
150
|
+
#{self}.list(
|
|
151
|
+
engine: 'required - provider name',
|
|
152
|
+
refresh: 'optional - true re-reads the Open WebUI workspace tool list',
|
|
153
|
+
model: 'optional - used to decide whether OpenAI hosted tools share the request'
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Translate hosted tool names into that provider's request objects.
|
|
157
|
+
#{self}.wire(
|
|
158
|
+
engine: 'required - provider name',
|
|
159
|
+
names: 'required - hosted tool names such as web_search or google_search'
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# True when hosted tools can share the same request as Registry client tools.
|
|
163
|
+
#{self}.mixable?(
|
|
164
|
+
engine: 'required - provider name',
|
|
165
|
+
model: 'optional - OpenAI model id; hosted tools share only a Responses request'
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# Append hosted tool objects to a provider body. Open WebUI is not mixed here.
|
|
169
|
+
#{self}.attach(
|
|
170
|
+
engine: 'required - provider name',
|
|
171
|
+
http_body: 'required - provider request body',
|
|
172
|
+
request: 'required - original chat options, including server_tools'
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Run one server-side tool and return its answer. Ask Jev uses Open WebUI.
|
|
176
|
+
#{self}.call(
|
|
177
|
+
name: 'required - workspace tool name or hosted tool name',
|
|
178
|
+
request: 'required - question or state to send with that tool',
|
|
179
|
+
engine: 'optional - provider name; Ask Jev uses Open WebUI when that provider is configured',
|
|
180
|
+
model: 'optional - overrides the provider model'
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# Print the AUTHOR(S) string for this module.
|
|
184
|
+
#{self}.authors
|
|
185
|
+
"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
private_class_method def self.selected(opts = {})
|
|
189
|
+
Array(opts.dig(:request, :server_tools)).map(&:to_s).reject(&:empty?)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
private_class_method def self.route(opts = {})
|
|
193
|
+
name = opts[:name].to_s
|
|
194
|
+
engine = opts[:engine].to_s.downcase
|
|
195
|
+
engine = PWN::Env.dig(:ai, :active).to_s.downcase if engine.empty? && defined?(PWN::Env)
|
|
196
|
+
return :openwebui if name.match?(/ask[[:space:]]*jev/i) && openwebui_ready?
|
|
197
|
+
|
|
198
|
+
engine.empty? ? :openwebui : engine.to_sym
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
private_class_method def self.openwebui_ready?
|
|
202
|
+
engine = PWN::Env.dig(:ai, :openwebui) if defined?(PWN::Env)
|
|
203
|
+
return false unless engine.is_a?(Hash)
|
|
204
|
+
|
|
205
|
+
PWN::AI::OpenWebUI.send(:real_config_value?, value: engine[:base_uri]) &&
|
|
206
|
+
PWN::AI::OpenWebUI.send(:real_config_value?, value: engine[:key])
|
|
207
|
+
rescue StandardError
|
|
208
|
+
false
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
private_class_method def self.openwebui_call(opts = {})
|
|
212
|
+
name = opts[:name].to_s
|
|
213
|
+
listed = PWN::AI::OpenWebUI.workspace_tools
|
|
214
|
+
return { available: true, engine: :openwebui, tools: listed } if name.empty?
|
|
215
|
+
|
|
216
|
+
response = PWN::AI::OpenWebUI.chat(request: opts[:request], server_tools: [name], model: opts[:model], spinner: false)
|
|
217
|
+
{ available: true, engine: :openwebui, name: name, content: answer_text(response: response), sources: response[:sources] || response['sources'] }
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
private_class_method def self.answer_text(opts = {})
|
|
221
|
+
response = opts[:response]
|
|
222
|
+
return '' unless response.is_a?(Hash)
|
|
223
|
+
|
|
224
|
+
message = response[:assistant_message] || response.dig(:choices, -1)
|
|
225
|
+
message = message[:message] if message.is_a?(Hash) && message[:message]
|
|
226
|
+
text = message.is_a?(Hash) ? (message[:content] || message['content']) : nil
|
|
227
|
+
text.to_s
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
data/lib/pwn/ai.rb
CHANGED
data/lib/pwn/backups.rb
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'digest'
|
|
6
|
+
require 'securerandom'
|
|
7
|
+
require 'find'
|
|
8
|
+
|
|
9
|
+
module PWN
|
|
10
|
+
# All first-party recovery copies share one serialized seven-generation store.
|
|
11
|
+
# Evidence artifacts are not recovery copies and must not enter this store.
|
|
12
|
+
module Backups
|
|
13
|
+
LIMIT = 7
|
|
14
|
+
GENERATION = /\A\d{8}_\d{6}_[a-f0-9]{8}\z/
|
|
15
|
+
|
|
16
|
+
# Supported Method Parameters::
|
|
17
|
+
# PWN::Backups.active?
|
|
18
|
+
public_class_method def self.active?
|
|
19
|
+
owner = Thread.current[:pwn_backup_transaction]
|
|
20
|
+
!owner.nil? && owner[:pid] == Process.pid && owner[:thread] == Thread.current
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Supported Method Parameters::
|
|
24
|
+
# PWN::Backups.transaction(root: nil) { ... }
|
|
25
|
+
public_class_method def self.transaction(opts = {})
|
|
26
|
+
owner = Thread.current[:pwn_backup_transaction]
|
|
27
|
+
override = Thread.current[:pwn_backup_root]
|
|
28
|
+
real_home = Thread.current[:pwn_backup_real_home]
|
|
29
|
+
override = nil if real_home && Dir.home != real_home
|
|
30
|
+
root = File.expand_path(opts[:root] || (owner[:root] if active?) || override || File.join(Dir.home, '.pwn', 'backup'))
|
|
31
|
+
return yield if owner && owner[:pid] == Process.pid && owner[:thread] == Thread.current && owner[:root] == root
|
|
32
|
+
raise IOError, 'Nested backup roots are not supported' if active?
|
|
33
|
+
|
|
34
|
+
learning = PWN::AI::Agent::Learning.send(:learning_lock)
|
|
35
|
+
safe_directory(path: root)
|
|
36
|
+
File.open(File.join(root, '.retention.lock'), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock|
|
|
37
|
+
raise IOError, 'Unsafe backup lock' unless lock.stat.uid == Process.uid && lock.stat.nlink == 1
|
|
38
|
+
|
|
39
|
+
lock.flock(File::LOCK_EX)
|
|
40
|
+
context = { pid: Process.pid, thread: Thread.current, root: root }
|
|
41
|
+
Thread.current[:pwn_backup_transaction] = context
|
|
42
|
+
begin
|
|
43
|
+
result = yield
|
|
44
|
+
failed = result.is_a?(Hash) && (result[:error] || result[:ok] == false || result[:promoted] == false || result[:incomplete] || %w[prepared interrupted blocked].include?(result[:state].to_s))
|
|
45
|
+
write_state(context: context, state: 'complete') if context[:directory] && !failed
|
|
46
|
+
result
|
|
47
|
+
ensure
|
|
48
|
+
# Exceptions leave a prepared recovery generation, never silently expire it.
|
|
49
|
+
Thread.current[:pwn_backup_transaction] = owner
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
ensure
|
|
53
|
+
PWN::AI::Agent::Learning.send(:learning_unlock, lock: learning) if learning
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Supported Method Parameters::
|
|
57
|
+
# PWN::Backups.directory(label: 'maintenance')
|
|
58
|
+
# Caller must hold transaction through the final source mutation and ledger.
|
|
59
|
+
public_class_method def self.directory(opts = {})
|
|
60
|
+
label = opts[:label].to_s
|
|
61
|
+
raise ArgumentError, 'Invalid backup label' unless label.match?(/\A[a-zA-Z0-9_-]+\z/)
|
|
62
|
+
|
|
63
|
+
context = Thread.current[:pwn_backup_transaction]
|
|
64
|
+
raise IOError, 'Backup transaction required' unless context && context[:pid] == Process.pid && context[:thread] == Thread.current
|
|
65
|
+
|
|
66
|
+
unless context[:directory]
|
|
67
|
+
root = context[:root]
|
|
68
|
+
names = Dir.children(root).select { |name| File.directory?(File.join(root, name)) || File.symlink?(File.join(root, name)) }.sort
|
|
69
|
+
pins = names.length >= LIMIT ? reference_pins(root: root) : []
|
|
70
|
+
raise IOError, 'Pinned backup missing' unless (pins - names).empty?
|
|
71
|
+
|
|
72
|
+
candidates = names.select do |name|
|
|
73
|
+
next false unless name.match?(GENERATION) && !pins.include?(name)
|
|
74
|
+
|
|
75
|
+
path = File.join(root, name)
|
|
76
|
+
safe_directory(path: path)
|
|
77
|
+
state = File.join(path, '.generation.json')
|
|
78
|
+
next false unless File.file?(state) && !File.symlink?(state)
|
|
79
|
+
|
|
80
|
+
record = JSON.parse(File.binread(state))
|
|
81
|
+
record['version'] == 1 && record['state'] == 'complete' && record['pinned'] != true
|
|
82
|
+
end
|
|
83
|
+
count = [names.length - LIMIT + 1, 0].max
|
|
84
|
+
raise IOError, 'Backup capacity occupied by legacy, pinned or interrupted generations' if candidates.length < count
|
|
85
|
+
|
|
86
|
+
candidates.sort_by { |name| File.mtime(File.join(root, name)).to_r }.first(count).each { |name| FileUtils.remove_entry_secure(File.join(root, name)) }
|
|
87
|
+
context[:directory] = File.join(root, "#{Time.now.utc.strftime('%Y%m%d_%H%M%S')}_#{SecureRandom.hex(4)}")
|
|
88
|
+
Dir.mkdir(context[:directory], 0o700)
|
|
89
|
+
write_state(context: context, state: 'prepared')
|
|
90
|
+
end
|
|
91
|
+
path = File.join(context[:directory], "#{label}-#{SecureRandom.hex(8)}")
|
|
92
|
+
Dir.mkdir(path, 0o700)
|
|
93
|
+
path
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Supported Method Parameters::
|
|
97
|
+
# PWN::Backups.capture(path: '/path/to/source', label: 'learning')
|
|
98
|
+
public_class_method def self.capture(opts = {})
|
|
99
|
+
source = File.expand_path(opts[:path])
|
|
100
|
+
label = opts[:label] || 'snapshot'
|
|
101
|
+
transaction do
|
|
102
|
+
Thread.current[:pwn_backup_transaction][:pinned] = true if opts[:pin]
|
|
103
|
+
raise IOError, 'Unsafe backup source' unless File.file?(source) && File.realpath(source) == source && File.stat(source).uid == Process.uid
|
|
104
|
+
|
|
105
|
+
directory = self.directory(label: label)
|
|
106
|
+
target = File.join(directory, File.basename(source))
|
|
107
|
+
before = Digest::SHA256.file(source).hexdigest
|
|
108
|
+
File.open(source, File::RDONLY | File::NOFOLLOW) do |input|
|
|
109
|
+
File.open(target, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |output|
|
|
110
|
+
IO.copy_stream(input, output)
|
|
111
|
+
output.flush
|
|
112
|
+
output.fsync
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
raise IOError, 'Backup source changed or copy failed' unless Digest::SHA256.file(source).hexdigest == before && Digest::SHA256.file(target).hexdigest == before
|
|
116
|
+
|
|
117
|
+
File.open(directory, File::RDONLY, &:fsync)
|
|
118
|
+
target
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Supported Method Parameters::
|
|
123
|
+
# PWN::Backups.configure(root: nil)
|
|
124
|
+
public_class_method def self.configure(opts = {})
|
|
125
|
+
root = opts[:root] || File.join(Dir.home, '.pwn', 'backup')
|
|
126
|
+
transaction(root: root) do
|
|
127
|
+
path = File.join(root, '.retention-policy.json')
|
|
128
|
+
raise IOError, 'Unsafe retention policy' if File.symlink?(path)
|
|
129
|
+
|
|
130
|
+
policy = File.file?(path) ? JSON.parse(File.binread(path)) : { 'version' => 1, 'pins' => [] }
|
|
131
|
+
raise IOError, 'Invalid retention policy' unless policy['version'] == 1 && policy['pins'].is_a?(Array)
|
|
132
|
+
|
|
133
|
+
updated = policy.merge('keep' => LIMIT, 'expire_history' => true, 'scope' => 'global')
|
|
134
|
+
next updated if updated == policy
|
|
135
|
+
|
|
136
|
+
temporary = "#{path}.#{SecureRandom.hex(8)}"
|
|
137
|
+
File.open(temporary, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
|
|
138
|
+
file.write(JSON.generate(updated))
|
|
139
|
+
file.flush
|
|
140
|
+
file.fsync
|
|
141
|
+
end
|
|
142
|
+
File.rename(temporary, path)
|
|
143
|
+
File.open(root, File::RDONLY, &:fsync)
|
|
144
|
+
updated
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Supported Method Parameters::
|
|
149
|
+
# PWN::Backups.reconcile(generation: 'name', expected_sha256: 'digest', confirmed: true)
|
|
150
|
+
public_class_method def self.reconcile(opts = {})
|
|
151
|
+
generation = opts[:generation].to_s
|
|
152
|
+
raise ArgumentError, 'Exact generation and explicit recovery confirmation required' unless generation.match?(GENERATION) && opts[:confirmed] == true
|
|
153
|
+
|
|
154
|
+
transaction do
|
|
155
|
+
root = Thread.current[:pwn_backup_transaction][:root]
|
|
156
|
+
directory = File.join(root, generation)
|
|
157
|
+
raise IOError, 'Recovery generation missing' unless File.directory?(directory)
|
|
158
|
+
|
|
159
|
+
safe_directory(path: directory)
|
|
160
|
+
path = File.join(directory, '.generation.json')
|
|
161
|
+
raise IOError, 'Recovery manifest changed' unless !File.symlink?(path) && Digest::SHA256.file(path).hexdigest == opts[:expected_sha256]
|
|
162
|
+
|
|
163
|
+
record = JSON.parse(File.read(path))
|
|
164
|
+
raise IOError, 'Unknown recovery manifest' unless record['version'] == 1 && %w[prepared complete].include?(record['state'])
|
|
165
|
+
|
|
166
|
+
write_state(context: { root: root, directory: directory, pinned: opts.fetch(:pinned, true) }, state: 'complete')
|
|
167
|
+
{ generation: generation, state: 'complete', pinned: opts.fetch(:pinned, true) }
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Supported Method Parameters::
|
|
172
|
+
# PWN::Backups.relocate_legacy(paths: [], dry_run: true, writers_stopped: false)
|
|
173
|
+
public_class_method def self.relocate_legacy(opts = {})
|
|
174
|
+
paths = Array(opts[:paths]).map { |path| File.expand_path(path) }.uniq
|
|
175
|
+
dry_run = opts.fetch(:dry_run, true)
|
|
176
|
+
raise ArgumentError, 'Stop all legacy writers before relocation' unless dry_run || opts[:writers_stopped] == true
|
|
177
|
+
|
|
178
|
+
home = Dir.home
|
|
179
|
+
root = File.join(home, '.pwn')
|
|
180
|
+
paths.each do |path|
|
|
181
|
+
relative = path.delete_prefix("#{root}/")
|
|
182
|
+
recognized = relative.match?(%r{\A(?:learning\.jsonl\.bak[^/]*|preferences\.jsonl\.bak[^/]*|skills/.+/SKILL\.md\.[a-f0-9]{64}\.bak|policy\.json\.rollback-[a-f0-9]{64}\.json|learning/migrations/[a-f0-9]{64}\.backup\.jsonl|session_cleanup_archive/[^/]+|maintenance/(?:retirements|quarantine)/[^/]+)\z})
|
|
183
|
+
recognized ||= path == File.join(home, '.config', 'terminator', 'config.pwnbak')
|
|
184
|
+
raise ArgumentError, 'Unrecognized legacy recovery path' unless recognized
|
|
185
|
+
raise IOError, 'Unsafe legacy recovery path' unless File.realpath(path) == path && File.stat(path).uid == Process.uid
|
|
186
|
+
end
|
|
187
|
+
raise ArgumentError, 'Overlapping legacy sources' if paths.any? { |path| paths.any? { |other| path != other && path.start_with?("#{other}/") } }
|
|
188
|
+
|
|
189
|
+
return { planned: paths, moved: [] } if dry_run
|
|
190
|
+
|
|
191
|
+
transaction do
|
|
192
|
+
legacy_unreferenced(paths: paths, root: root)
|
|
193
|
+
mappings = paths.to_h do |path|
|
|
194
|
+
target = File.join(directory(label: 'legacy'), File.basename(path))
|
|
195
|
+
before = legacy_inventory(path: path)
|
|
196
|
+
FileUtils.cp_r(path, target, preserve: true)
|
|
197
|
+
raise IOError, 'Legacy recovery changed during copy' unless before == legacy_inventory(path: path) && before == legacy_inventory(path: target)
|
|
198
|
+
|
|
199
|
+
[path, { target: target, inventory: before }]
|
|
200
|
+
end
|
|
201
|
+
legacy_unreferenced(paths: paths, root: root)
|
|
202
|
+
mappings.each do |path, row|
|
|
203
|
+
raise IOError, 'Legacy recovery changed before relocation' unless row[:inventory] == legacy_inventory(path: path)
|
|
204
|
+
end
|
|
205
|
+
ledger = File.join(Thread.current[:pwn_backup_transaction][:directory], '.relocation.json')
|
|
206
|
+
File.open(ledger, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
|
|
207
|
+
file.write(JSON.generate(version: 1, mappings: mappings))
|
|
208
|
+
file.flush
|
|
209
|
+
file.fsync
|
|
210
|
+
end
|
|
211
|
+
mappings.each_key { |path| FileUtils.remove_entry_secure(path) }
|
|
212
|
+
{ moved: mappings.transform_values { |row| row[:target] }, ledger: ledger }
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
private_class_method def self.legacy_inventory(opts = {})
|
|
217
|
+
path = opts[:path]
|
|
218
|
+
rows = {}
|
|
219
|
+
Find.find(path) do |file|
|
|
220
|
+
stat = File.lstat(file)
|
|
221
|
+
raise IOError, 'Unsafe legacy recovery entry' unless (stat.file? || stat.directory?) && stat.uid == Process.uid
|
|
222
|
+
|
|
223
|
+
rows[file.delete_prefix(path)] = [stat.ftype, stat.mode, stat.file? ? Digest::SHA256.file(file).hexdigest : nil]
|
|
224
|
+
File.open(file, File::RDONLY, &:fsync)
|
|
225
|
+
end
|
|
226
|
+
rows
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
private_class_method def self.legacy_unreferenced(opts = {})
|
|
230
|
+
paths = opts[:paths]
|
|
231
|
+
root = opts[:root]
|
|
232
|
+
return unless File.directory?(root)
|
|
233
|
+
|
|
234
|
+
Find.find(root) do |file|
|
|
235
|
+
Find.prune if file == File.join(root, 'backup') || paths.include?(file)
|
|
236
|
+
raise IOError, 'Unsafe legacy reference scope' if File.symlink?(file)
|
|
237
|
+
next unless File.file?(file) && file.match?(/\.jsonl?\z/)
|
|
238
|
+
|
|
239
|
+
body = File.binread(file)
|
|
240
|
+
raise IOError, 'Legacy recovery is referenced; preserve original paths' if paths.any? { |path| body.include?(path) || body.include?(path.delete_prefix("#{root}/")) }
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
private_class_method def self.reference_pins(opts = {})
|
|
245
|
+
root = opts[:root]
|
|
246
|
+
policy = File.join(root, '.retention-policy.json')
|
|
247
|
+
raise IOError, 'Unsafe retention policy' if File.symlink?(policy)
|
|
248
|
+
|
|
249
|
+
pins = File.file?(policy) ? JSON.parse(File.binread(policy)).fetch('pins') : []
|
|
250
|
+
raise IOError, 'Invalid backup pins' unless pins.is_a?(Array) && pins.all? { |name| name.is_a?(String) && name.match?(/\A\d{8}_\d{6}(?:_[a-f0-9]{8})?\z/) }
|
|
251
|
+
|
|
252
|
+
pins = pins.dup
|
|
253
|
+
state_root = File.dirname(root)
|
|
254
|
+
names = %w[learning learning.jsonl memory.json mistakes.json policy.json policy_traj.jsonl preferences.jsonl engagements artifacts sessions swarm jobs runs missions open_goals open_goal.json curriculum finetune extrospection skill_review.jsonl maintenance]
|
|
255
|
+
names.each do |name|
|
|
256
|
+
path = File.join(state_root, name)
|
|
257
|
+
next unless File.exist?(path) || File.symlink?(path)
|
|
258
|
+
|
|
259
|
+
Find.find(path) do |file|
|
|
260
|
+
stat = File.lstat(file)
|
|
261
|
+
raise IOError, 'Unsafe backup reference entry' unless (stat.file? || stat.directory?) && stat.uid == Process.uid
|
|
262
|
+
next unless stat.file? && file.match?(/\.jsonl?\z/)
|
|
263
|
+
|
|
264
|
+
admission = Thread.current[:pwn_maintenance_admission]
|
|
265
|
+
raise IOError, 'Backup reference budget exhausted' if admission && !admission.call(stat.size + 1)
|
|
266
|
+
|
|
267
|
+
values = file.end_with?('.jsonl') ? File.foreach(file).reject { |line| line.strip.empty? }.map { |line| JSON.parse(line) } : [JSON.parse(File.binread(file))]
|
|
268
|
+
values.select! { |value| value.is_a?(Hash) && (%w[prepared interrupted].include?(value['state']) || value['pinned'] == true) } if name == 'maintenance'
|
|
269
|
+
until values.empty?
|
|
270
|
+
value = values.pop
|
|
271
|
+
case value
|
|
272
|
+
when Hash then values.concat(value.values)
|
|
273
|
+
when Array then values.concat(value)
|
|
274
|
+
when String
|
|
275
|
+
match = value.match(%r{\A(?:#{Regexp.escape(root)}/|backup/)(\d{8}_\d{6}(?:_[a-f0-9]{8})?)(?:/|\z)})
|
|
276
|
+
pins << match[1] if match
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
pins.uniq
|
|
282
|
+
rescue JSON::ParserError, KeyError => e
|
|
283
|
+
raise IOError, "Unreadable backup references: #{e.class}"
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
private_class_method def self.safe_directory(opts = {})
|
|
287
|
+
path = opts[:path]
|
|
288
|
+
ancestor = path
|
|
289
|
+
loop do
|
|
290
|
+
raise IOError, 'Symlinked backup ancestor' if File.symlink?(ancestor)
|
|
291
|
+
break if File.dirname(ancestor) == ancestor
|
|
292
|
+
|
|
293
|
+
ancestor = File.dirname(ancestor)
|
|
294
|
+
end
|
|
295
|
+
FileUtils.mkdir_p(path, mode: 0o700)
|
|
296
|
+
stat = File.stat(path)
|
|
297
|
+
if stat.uid == Process.uid && !stat.mode.nobits?(0o077)
|
|
298
|
+
File.chmod(0o700, path)
|
|
299
|
+
stat = File.stat(path)
|
|
300
|
+
end
|
|
301
|
+
raise IOError, 'Unsafe backup directory' unless stat.uid == Process.uid && stat.mode.nobits?(0o077)
|
|
302
|
+
|
|
303
|
+
path
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
private_class_method def self.write_state(opts = {})
|
|
307
|
+
context = opts[:context]
|
|
308
|
+
path = File.join(context[:directory], '.generation.json')
|
|
309
|
+
temporary = "#{path}.tmp"
|
|
310
|
+
File.open(temporary, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
|
|
311
|
+
file.write(JSON.generate(version: 1, state: opts[:state], pinned: context[:pinned] == true))
|
|
312
|
+
file.flush
|
|
313
|
+
file.fsync
|
|
314
|
+
end
|
|
315
|
+
File.rename(temporary, path)
|
|
316
|
+
File.open(context[:directory], File::RDONLY, &:fsync)
|
|
317
|
+
File.open(context[:root], File::RDONLY, &:fsync)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Author(s):: 0day Inc. <support@0dayinc.com>
|
|
321
|
+
public_class_method def self.authors
|
|
322
|
+
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Display Usage for this Module
|
|
326
|
+
public_class_method def self.help
|
|
327
|
+
puts "
|
|
328
|
+
# Hold the shared lock through backup and source mutation.
|
|
329
|
+
#{self}.transaction(root: 'optional - backup root; defaults to Thread.current[:pwn_backup_root] or ~/.pwn/backup') { ... }
|
|
330
|
+
# Allocate a producer directory inside the current generation.
|
|
331
|
+
#{self}.directory(label: 'required - producer label')
|
|
332
|
+
# Copy and hash-verify an owned regular file.
|
|
333
|
+
#{self}.capture(path: 'required - source path', label: 'optional - producer label', pin: 'optional - retain until explicitly reconciled')
|
|
334
|
+
# Install the global seven-folder policy without expiring existing data.
|
|
335
|
+
#{self}.configure(root: 'optional - backup root; defaults ~/.pwn/backup')
|
|
336
|
+
# Acknowledge completed store-specific recovery; this does not restore data.
|
|
337
|
+
#{self}.reconcile(generation: 'required - exact generation name', expected_sha256: 'required - inspected generation manifest digest', confirmed: 'required - explicit true after store recovery', pinned: 'optional - retain by default; false permits ordinary retention')
|
|
338
|
+
# Explicitly relocate recognized unreferenced legacy recovery copies.
|
|
339
|
+
#{self}.relocate_legacy(paths: 'required - exact legacy paths', dry_run: 'optional - preview only by default', writers_stopped: 'optional - must be true to apply')
|
|
340
|
+
# Check whether this thread owns the backup transaction.
|
|
341
|
+
#{self}.active?
|
|
342
|
+
# Show module authors.
|
|
343
|
+
#{self}.authors
|
|
344
|
+
"
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|