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
|
@@ -101,7 +101,14 @@ module PWN
|
|
|
101
101
|
# Plan by default; no provider calls, vault reads, transcript logging or deletion.
|
|
102
102
|
public_class_method def self.migrate_evidence(opts = {})
|
|
103
103
|
dry = opts[:dry_run] != false
|
|
104
|
-
|
|
104
|
+
max_sessions = opts[:max_sessions]
|
|
105
|
+
stop = opts[:stop]
|
|
106
|
+
raise ArgumentError, 'max_sessions must be positive' if max_sessions && (!max_sessions.is_a?(Integer) || max_sessions <= 0)
|
|
107
|
+
|
|
108
|
+
leases = []
|
|
109
|
+
lock = learning_lock(nonblocking: opts[:nonblocking]) unless dry
|
|
110
|
+
return { dry_run: false, deferred: 'learning_writer_active', migrated_rows: 0 } if lock == :busy
|
|
111
|
+
|
|
105
112
|
original = File.exist?(LEARNING_FILE) ? evidence_read(path: LEARNING_FILE) : ''
|
|
106
113
|
malformed = 0
|
|
107
114
|
rows = original.lines.filter_map do |line|
|
|
@@ -116,9 +123,13 @@ module PWN
|
|
|
116
123
|
|
|
117
124
|
report = { dry_run: dry, rows: rows.length, malformed_rows: malformed, session_refs: 0, migratable_sessions: 0,
|
|
118
125
|
missing_sessions: 0, malformed_sessions: 0, malformed_refs: 0, verified_rows: 0,
|
|
119
|
-
estimated_source_bytes: 0, migrated_rows: 0 }
|
|
126
|
+
estimated_source_bytes: 0, migrated_rows: 0, deferred_active_sessions: 0 }
|
|
120
127
|
snapshots = {}
|
|
121
128
|
rows.each do |row|
|
|
129
|
+
if stop&.call || (max_sessions && report[:migratable_sessions] >= max_sessions)
|
|
130
|
+
report[:bounded] = true
|
|
131
|
+
break
|
|
132
|
+
end
|
|
122
133
|
sid = row[:session_id]
|
|
123
134
|
next if sid.nil? || sid == ''
|
|
124
135
|
|
|
@@ -140,6 +151,22 @@ module PWN
|
|
|
140
151
|
next
|
|
141
152
|
end
|
|
142
153
|
begin
|
|
154
|
+
# Keep the permanent exclusive lease through index publication;
|
|
155
|
+
# a point-in-time active? check alone races subsequent activation.
|
|
156
|
+
if dry
|
|
157
|
+
if PWN::Sessions.active?(session_id: sid)
|
|
158
|
+
report[:deferred_active_sessions] += 1
|
|
159
|
+
next
|
|
160
|
+
end
|
|
161
|
+
else
|
|
162
|
+
lease = File.open(PWN::Sessions.support_path(kind: :lease, session_id: sid), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) # rubocop:disable Style/FileOpen -- released together after commit
|
|
163
|
+
leases << lease
|
|
164
|
+
unless lease.flock(File::LOCK_EX | File::LOCK_NB)
|
|
165
|
+
lease.close
|
|
166
|
+
report[:deferred_active_sessions] += 1
|
|
167
|
+
next
|
|
168
|
+
end
|
|
169
|
+
end
|
|
143
170
|
body = evidence_read(path: path)
|
|
144
171
|
transcript = body.lines.map { |line| JSON.parse(line, symbolize_names: true) }
|
|
145
172
|
raise IOError, 'Malformed source transcript' if transcript.empty? || !transcript.all?(Hash)
|
|
@@ -179,7 +206,8 @@ module PWN
|
|
|
179
206
|
|
|
180
207
|
evidence_commit(rows: rows, original: original, snapshots: snapshots, report: report)
|
|
181
208
|
ensure
|
|
182
|
-
|
|
209
|
+
leases&.each { |lease| lease.close unless lease.closed? }
|
|
210
|
+
learning_unlock(lock: lock) if lock && lock != :busy
|
|
183
211
|
end
|
|
184
212
|
|
|
185
213
|
private_class_method def self.evidence_read(opts = {})
|
|
@@ -233,7 +261,7 @@ module PWN
|
|
|
233
261
|
root = File.join(File.dirname(LEARNING_FILE), 'learning', 'migrations')
|
|
234
262
|
path = File.join(root, "#{id}.json")
|
|
235
263
|
record = JSON.parse(evidence_read(path: path), symbolize_names: true)
|
|
236
|
-
backup = evidence_read(path: File.join(root, "#{id}.backup.jsonl"))
|
|
264
|
+
backup = evidence_read(path: record[:backup] || File.join(root, "#{id}.backup.jsonl"))
|
|
237
265
|
raise IOError, 'Invalid migration backup' unless record[:before_sha256] == id && Digest::SHA256.hexdigest(backup) == id
|
|
238
266
|
|
|
239
267
|
current = evidence_read(path: LEARNING_FILE)
|
|
@@ -253,6 +281,8 @@ module PWN
|
|
|
253
281
|
|
|
254
282
|
raise IOError, 'Restore source sessions before rollback' unless evidence_complete?(row: row) && File.file?(File.join(PWN::Sessions::SESSIONS_DIR, "#{sid}.jsonl"))
|
|
255
283
|
end
|
|
284
|
+
return { dry_run: true, restored: false } if opts[:dry_run] == true
|
|
285
|
+
|
|
256
286
|
evidence_write(path: LEARNING_FILE, body: backup)
|
|
257
287
|
evidence_write(path: path, body: JSON.generate(record.merge(state: 'rolled_back')))
|
|
258
288
|
{ restored: true }
|
|
@@ -290,11 +320,12 @@ module PWN
|
|
|
290
320
|
end
|
|
291
321
|
|
|
292
322
|
private_class_method def self.evidence_commit(opts = {})
|
|
323
|
+
return PWN::Backups.transaction { evidence_commit(opts) } unless PWN::Backups.active?
|
|
324
|
+
|
|
293
325
|
rows, original, snapshots, report = opts.values_at(:rows, :original, :snapshots, :report)
|
|
294
326
|
root = File.join(File.dirname(LEARNING_FILE), 'learning')
|
|
295
327
|
before_hash = Digest::SHA256.hexdigest(original)
|
|
296
|
-
backup =
|
|
297
|
-
evidence_write(path: backup, body: original) unless File.exist?(backup)
|
|
328
|
+
backup = PWN::Backups.capture(path: LEARNING_FILE, label: 'learning-migration')
|
|
298
329
|
raise IOError, 'Invalid migration backup' unless evidence_read(path: backup) == original
|
|
299
330
|
|
|
300
331
|
rows.each do |row|
|
|
@@ -310,7 +341,7 @@ module PWN
|
|
|
310
341
|
end
|
|
311
342
|
updated = rows.map { |row| "#{JSON.generate(row)}\n" }.join
|
|
312
343
|
ledger = File.join(root, 'migrations', "#{before_hash}.json")
|
|
313
|
-
record = { version: 1, state: 'prepared', before_sha256: before_hash,
|
|
344
|
+
record = { version: 1, state: 'prepared', backup: backup, before_sha256: before_hash,
|
|
314
345
|
after_sha256: Digest::SHA256.hexdigest(updated), migrated_rows: report[:migrated_rows] }
|
|
315
346
|
evidence_write(path: ledger, body: JSON.generate(record))
|
|
316
347
|
raise IOError, 'Learning changed during migration' unless evidence_read(path: LEARNING_FILE) == original
|
|
@@ -332,10 +363,11 @@ module PWN
|
|
|
332
363
|
end
|
|
333
364
|
|
|
334
365
|
# Reentrant across note_outcome -> pruning; flock serializes cooperating processes.
|
|
335
|
-
private_class_method def self.learning_lock
|
|
366
|
+
private_class_method def self.learning_lock(opts = {})
|
|
336
367
|
key = :pwn_learning_write_lock
|
|
337
368
|
owner = Thread.current[key]
|
|
338
369
|
return nil if owner.is_a?(Hash) && owner[:pid] == Process.pid && owner[:thread] == Thread.current && !owner[:file].closed?
|
|
370
|
+
raise IOError, 'Acquire the learning writer lock before the backup transaction' if PWN::Backups.active?
|
|
339
371
|
|
|
340
372
|
root = File.dirname(LEARNING_FILE)
|
|
341
373
|
FileUtils.mkdir_p(root, mode: 0o700)
|
|
@@ -344,7 +376,10 @@ module PWN
|
|
|
344
376
|
|
|
345
377
|
# Ownership transfers to the caller's ensure/learning_unlock.
|
|
346
378
|
file = File.open(path, File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) # rubocop:disable Style/FileOpen
|
|
347
|
-
file.flock(File::LOCK_EX)
|
|
379
|
+
unless file.flock(File::LOCK_EX | (opts[:nonblocking] ? File::LOCK_NB : 0))
|
|
380
|
+
file.close
|
|
381
|
+
return :busy
|
|
382
|
+
end
|
|
348
383
|
Thread.current[key] = { pid: Process.pid, thread: Thread.current, file: file }
|
|
349
384
|
file
|
|
350
385
|
end
|
|
@@ -2199,12 +2234,13 @@ module PWN
|
|
|
2199
2234
|
end
|
|
2200
2235
|
|
|
2201
2236
|
private_class_method def self.atomic_jsonl_write(opts = {})
|
|
2237
|
+
return PWN::Backups.transaction { atomic_jsonl_write(opts) } unless PWN::Backups.active?
|
|
2238
|
+
|
|
2202
2239
|
path = opts[:path]
|
|
2203
2240
|
rows = Array(opts[:rows])
|
|
2204
2241
|
dir = File.dirname(path)
|
|
2205
2242
|
FileUtils.mkdir_p(dir)
|
|
2206
|
-
|
|
2207
|
-
FileUtils.cp(path, bak) if File.exist?(path) && !File.exist?(bak)
|
|
2243
|
+
PWN::Backups.capture(path: path, label: 'learning') if File.exist?(path)
|
|
2208
2244
|
tmp = File.join(dir, ".#{File.basename(path)}.#{Process.pid}.tmp")
|
|
2209
2245
|
File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC, 0o644) do |f|
|
|
2210
2246
|
f.flock(File::LOCK_EX)
|
|
@@ -2365,16 +2401,9 @@ module PWN
|
|
|
2365
2401
|
end
|
|
2366
2402
|
|
|
2367
2403
|
public_class_method def self.compact!(opts = {})
|
|
2368
|
-
|
|
2369
|
-
max_baks
|
|
2370
|
-
|
|
2371
|
-
baks = Dir[File.join(dir, '*.bak*')].sort_by { |p| File.mtime(p) }.reverse
|
|
2372
|
-
pruned = 0
|
|
2373
|
-
baks.drop(max_baks).each do |path|
|
|
2374
|
-
File.delete(path)
|
|
2375
|
-
pruned += 1
|
|
2376
|
-
end
|
|
2377
|
-
{ pruned: pruned, kept: [baks.length, max_baks].min, max_baks: max_baks }
|
|
2404
|
+
# Compatibility option cannot override the shared total retention cap.
|
|
2405
|
+
opts[:max_baks]
|
|
2406
|
+
{ pruned: 0, max_baks: PWN::Backups::LIMIT, scope: 'global', legacy: 'explicit migration required' }
|
|
2378
2407
|
end
|
|
2379
2408
|
|
|
2380
2409
|
# Author(s):: 0day Inc. <support@0dayinc.com>
|
|
@@ -2388,10 +2417,18 @@ module PWN
|
|
|
2388
2417
|
public_class_method def self.help
|
|
2389
2418
|
puts "
|
|
2390
2419
|
# Plan a full-snapshot evidence migration, or explicitly apply it.
|
|
2391
|
-
#{self}.migrate_evidence(
|
|
2420
|
+
#{self}.migrate_evidence(
|
|
2421
|
+
dry_run: 'optional - Boolean; defaults true, false writes evidence and references',
|
|
2422
|
+
max_sessions: 'optional - Positive maximum source snapshots per transaction',
|
|
2423
|
+
nonblocking: 'optional - Boolean; defer instead of waiting for a cooperating learning writer',
|
|
2424
|
+
stop: 'optional - Cooperative stop callable checked between source snapshots'
|
|
2425
|
+
)
|
|
2392
2426
|
|
|
2393
2427
|
# Restore an unchanged learning ledger before any source-session cleanup.
|
|
2394
|
-
#{self}.rollback_evidence(
|
|
2428
|
+
#{self}.rollback_evidence(
|
|
2429
|
+
migration: 'required - SHA-256 migration ID from the ledger filename',
|
|
2430
|
+
dry_run: 'optional - validate rollback without replacing learning bytes; defaults to false'
|
|
2431
|
+
)
|
|
2395
2432
|
|
|
2396
2433
|
# Persist derived PRM rewards without rewriting immutable supporting content.
|
|
2397
2434
|
#{self}.annotate_evidence(
|
|
@@ -2438,9 +2475,9 @@ module PWN
|
|
|
2438
2475
|
dry_run: 'optional - true to count without writing'
|
|
2439
2476
|
)
|
|
2440
2477
|
|
|
2441
|
-
#
|
|
2478
|
+
# Report the shared global seven-generation retention policy.
|
|
2442
2479
|
#{self}.compact!(
|
|
2443
|
-
max_baks: 'optional -
|
|
2480
|
+
max_baks: 'optional - legacy compatibility value; cannot override global seven-folder limit'
|
|
2444
2481
|
)
|
|
2445
2482
|
|
|
2446
2483
|
# Export an inert regression contract from a persisted verified failure.
|
data/lib/pwn/ai/agent/loop.rb
CHANGED
|
@@ -990,6 +990,15 @@ module PWN
|
|
|
990
990
|
text.sub(%r{\s*\[/UNTRUSTED TOOL OUTPUT\]\s*\z}m, '')
|
|
991
991
|
end
|
|
992
992
|
|
|
993
|
+
private_class_method def self.terminal_effect_blocker(opts = {})
|
|
994
|
+
runtime = opts[:runtime]
|
|
995
|
+
diagnostic = runtime&.blocker
|
|
996
|
+
return nil unless diagnostic
|
|
997
|
+
|
|
998
|
+
runtime.finish!(status: 'blocked', unmet: runtime.unmet + [diagnostic])
|
|
999
|
+
"[pwn-ai] blocked: #{diagnostic}"
|
|
1000
|
+
end
|
|
1001
|
+
|
|
993
1002
|
private_class_method def self.declared_deliverables(opts = {})
|
|
994
1003
|
Array(declared_contract(request: opts[:request])[:paths])
|
|
995
1004
|
end
|
|
@@ -1004,7 +1013,7 @@ module PWN
|
|
|
1004
1013
|
end
|
|
1005
1014
|
# Paths are user-owned. Inference cannot invent a destination or
|
|
1006
1015
|
# convert the source document into a write obligation.
|
|
1007
|
-
contract = contract.merge(paths: literals
|
|
1016
|
+
contract = contract.merge(paths: literals)
|
|
1008
1017
|
Thread.current[:pwn_loop_deliverables] = contract if Thread.current[:pwn_loop_active]
|
|
1009
1018
|
contract
|
|
1010
1019
|
end
|
|
@@ -2091,6 +2100,10 @@ module PWN
|
|
|
2091
2100
|
model = opts[:model] || Thread.current[:pwn_swarm_model] || config[:model]
|
|
2092
2101
|
cwt_opts[:model] = model unless model.to_s.strip.empty?
|
|
2093
2102
|
cwt_opts[:temp] = opts[:temp] unless opts[:temp].nil?
|
|
2103
|
+
if PWN::AI::ServerTools.mixable?(engine: engine, model: cwt_opts[:model])
|
|
2104
|
+
hosted = Array(config[:server_tools]).map(&:to_s).reject(&:empty?)
|
|
2105
|
+
cwt_opts[:server_tools] = hosted if hosted.any?
|
|
2106
|
+
end
|
|
2094
2107
|
if tools && !tools.empty?
|
|
2095
2108
|
env_tc = begin
|
|
2096
2109
|
config[:tool_choice]
|
|
@@ -3637,6 +3650,9 @@ module PWN
|
|
|
3637
3650
|
return "[pwn-ai] #{status}: explicit request verification did not pass."
|
|
3638
3651
|
end
|
|
3639
3652
|
end
|
|
3653
|
+
effect_blocker = terminal_effect_blocker(runtime: runtime)
|
|
3654
|
+
return effect_blocker if effect_blocker
|
|
3655
|
+
|
|
3640
3656
|
# Completion is an evidence decision, never a prose classifier.
|
|
3641
3657
|
unless may_finalize?(
|
|
3642
3658
|
request: request,
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'socket'
|
|
4
|
+
require 'fiddle'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'date'
|
|
7
|
+
require 'rbconfig'
|
|
8
|
+
require 'timeout'
|
|
9
|
+
|
|
10
|
+
# Load root autoloads before opening nested namespaces on direct require.
|
|
11
|
+
require 'pwn'
|
|
12
|
+
|
|
13
|
+
module PWN
|
|
14
|
+
module AI
|
|
15
|
+
module Agent
|
|
16
|
+
# Exec-based user namespace bootstrap; the terminal never enters a namespace.
|
|
17
|
+
# Only typed descriptors cross the private socket. Ruby closures are permitted
|
|
18
|
+
# below an existing owner, where fork does not need a new user namespace.
|
|
19
|
+
class MissionNamespace # rubocop:disable Metrics/ClassLength -- one exec handshake and process ownership protocol
|
|
20
|
+
class Unavailable < StandardError; end
|
|
21
|
+
CONTEXT_KEYS = %i[pwn_swarm_engine pwn_swarm_model pwn_swarm_id pwn_swarm_depth pwn_solve_workspace pwn_solve_tools
|
|
22
|
+
pwn_session_id pwn_artifact_contract pwn_active_steering_request pwn_turn_finalizer_depth
|
|
23
|
+
pwn_reflect_depth pwn_request_intent pwn_loop_budget_s pwn_loop_mutations pwn_loop_tokens].freeze
|
|
24
|
+
attr_reader :status
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
attr_accessor :owned, :bootstrap_requires, :force_exec
|
|
28
|
+
|
|
29
|
+
def prepared?
|
|
30
|
+
rows = File.readlines('/proc/self/uid_map').map { |line| line.split.map(&:to_i) }
|
|
31
|
+
count = rows.length == 1 ? rows.first.last : nil
|
|
32
|
+
# The initial host map is the full 32-bit range. Any smaller single
|
|
33
|
+
# line is a user namespace, including a root test bootstrap that must
|
|
34
|
+
# still include a non-root account.
|
|
35
|
+
count.is_a?(Integer) && count.positive? && count < 4_294_967_295
|
|
36
|
+
rescue SystemCallError
|
|
37
|
+
false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def io(number)
|
|
41
|
+
stream = Thread.current[:pwn_mission_fds]&.fetch(number, nil) || IO.for_fd(number)
|
|
42
|
+
stream.close_on_exec = true if number > 2
|
|
43
|
+
stream
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def native(name, arguments)
|
|
47
|
+
Fiddle::Function.new(Fiddle::Handle::DEFAULT[name], arguments, Fiddle::TYPE_INT)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def checked(result, operation)
|
|
51
|
+
raise Unavailable, "#{operation}: #{SystemCallError.new(operation, Fiddle.last_error).message}; install util-linux unshare and enable Linux user/PID namespaces and pidfds" if result.negative?
|
|
52
|
+
|
|
53
|
+
result
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Tagged JSON preserves Symbol hash keys and values without deserializing
|
|
57
|
+
# executable Ruby objects or placing credentials in argv or temporary files.
|
|
58
|
+
def encode(value, depth = 0)
|
|
59
|
+
raise ArgumentError, 'mission snapshot is too deep or cyclic' if depth > 30
|
|
60
|
+
|
|
61
|
+
case value
|
|
62
|
+
when Hash then ['hash', value.map { |key, item| [encode(key, depth + 1), encode(item, depth + 1)] }]
|
|
63
|
+
when Array then ['array', value.map { |item| encode(item, depth + 1) }]
|
|
64
|
+
when Time then ['time', value.to_i, value.nsec, value.utc_offset, value.utc?]
|
|
65
|
+
when DateTime then ['datetime', value.iso8601(9)]
|
|
66
|
+
when Date then ['date', value.iso8601]
|
|
67
|
+
when Symbol then ['symbol', value.to_s]
|
|
68
|
+
when String then ['string', [value.b].pack('m0'), value.encoding.name]
|
|
69
|
+
when NilClass, TrueClass, FalseClass, Numeric then ['scalar', value]
|
|
70
|
+
else raise ArgumentError, "mission snapshot cannot transfer #{value.class}; use a serializable descriptor"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def decode(value)
|
|
75
|
+
type, body, encoding = value
|
|
76
|
+
case type
|
|
77
|
+
when 'hash' then body.to_h { |key, item| [decode(key), decode(item)] }
|
|
78
|
+
when 'array' then body.map { |item| decode(item) }
|
|
79
|
+
when 'time' then value[4] ? Time.at(body, encoding, :nanosecond).utc : Time.at(body, encoding, :nanosecond).getlocal(value[3])
|
|
80
|
+
when 'datetime' then DateTime.iso8601(body)
|
|
81
|
+
when 'date' then Date.iso8601(body)
|
|
82
|
+
when 'symbol' then body.to_sym
|
|
83
|
+
when 'string' then body.unpack1('m0').force_encoding(encoding)
|
|
84
|
+
when 'scalar' then body
|
|
85
|
+
else raise ArgumentError, 'invalid mission snapshot'
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def supervise
|
|
90
|
+
channel = UNIXSocket.for_fd(3)
|
|
91
|
+
snapshot = decode(JSON.parse(channel.gets))
|
|
92
|
+
checked(native('unshare', [Fiddle::TYPE_INT]).call(0x20000000), 'unshare PID namespace')
|
|
93
|
+
reader, writer = IO.pipe
|
|
94
|
+
armed, arm = IO.pipe
|
|
95
|
+
init = fork do
|
|
96
|
+
armed.close
|
|
97
|
+
checked(native('prctl', [Fiddle::TYPE_INT, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG]).call(1, Signal.list.fetch('KILL'), 0, 0, 0), 'PR_SET_PDEATHSIG')
|
|
98
|
+
arm.write('1')
|
|
99
|
+
arm.close
|
|
100
|
+
channel.close
|
|
101
|
+
writer.close
|
|
102
|
+
exit! unless reader.read(1) == '1'
|
|
103
|
+
|
|
104
|
+
reader.close
|
|
105
|
+
self.owned = true
|
|
106
|
+
self.bootstrap_requires = snapshot.fetch(:requires, [])
|
|
107
|
+
PWN::Env.replace(decode(encode(snapshot.fetch(:env))))
|
|
108
|
+
bootstrap_requires.each { |path| require path }
|
|
109
|
+
PWN::Env.replace(snapshot.fetch(:env))
|
|
110
|
+
PWN.const_set(:Skills, snapshot[:skills]) if snapshot[:skills] && !PWN.const_defined?(:Skills)
|
|
111
|
+
snapshot.fetch(:locals).each { |key, value| Thread.current[key] = value }
|
|
112
|
+
begin
|
|
113
|
+
Thread.handle_interrupt(Exception => :immediate) { execute(snapshot.fetch(:descriptor)) }
|
|
114
|
+
completed = true
|
|
115
|
+
ensure
|
|
116
|
+
exit!(completed ? 0 : 1)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
arm.close
|
|
120
|
+
raise Unavailable, 'namespace init exited before arming parent-death protection' unless armed.read(1) == '1'
|
|
121
|
+
|
|
122
|
+
armed.close
|
|
123
|
+
reader.close
|
|
124
|
+
fd = IO.new(checked(native('pidfd_open', [Fiddle::TYPE_INT, Fiddle::TYPE_INT]).call(init, 0), 'pidfd_open'))
|
|
125
|
+
channel.send_io(fd)
|
|
126
|
+
exit! unless channel.read(1) == '1'
|
|
127
|
+
|
|
128
|
+
writer.write('1')
|
|
129
|
+
writer.close
|
|
130
|
+
readable = IO.select([channel, fd]).first
|
|
131
|
+
native('pidfd_send_signal', [Fiddle::TYPE_INT, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT]).call(fd.fileno, Signal.list.fetch('KILL'), 0, 0) if readable.include?(channel)
|
|
132
|
+
_pid, status = Process.wait2(init)
|
|
133
|
+
init = nil
|
|
134
|
+
fd.close
|
|
135
|
+
status.exitstatus || (128 + status.termsig)
|
|
136
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- startup must fail closed
|
|
137
|
+
warn "Mission namespace unavailable: #{e.class}: #{e.message}"
|
|
138
|
+
ensure
|
|
139
|
+
if init
|
|
140
|
+
begin
|
|
141
|
+
Process.kill('KILL', init)
|
|
142
|
+
rescue Errno::ESRCH
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
begin
|
|
146
|
+
Process.wait(init)
|
|
147
|
+
rescue Errno::ECHILD
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
channel&.close unless channel&.closed?
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def execute(descriptor)
|
|
155
|
+
case descriptor.fetch(:operation).to_sym
|
|
156
|
+
when :command
|
|
157
|
+
_pid, status = Process.wait2(Process.spawn(*descriptor.fetch(:argv)))
|
|
158
|
+
exit!(status.exitstatus || (128 + status.termsig))
|
|
159
|
+
when :loop
|
|
160
|
+
require 'pwn/ai/cli'
|
|
161
|
+
PWN::AI::CLI.send(:mission_entry, descriptor)
|
|
162
|
+
when :protected
|
|
163
|
+
PWN::Plugins::AISandbox.send(:mission_entry, descriptor)
|
|
164
|
+
when :console, :slash
|
|
165
|
+
PWN::Plugins::REPL::AIConsole::Console.send(:mission_entry, descriptor: descriptor)
|
|
166
|
+
when :legacy, :steerable
|
|
167
|
+
PWN::Plugins::REPL.pwn_ai_mission_entry(descriptor)
|
|
168
|
+
when :swarm
|
|
169
|
+
PWN::Plugins::REPL::AISwarm::Controller.send(:mission_entry, descriptor: descriptor)
|
|
170
|
+
else
|
|
171
|
+
raise ArgumentError, 'unknown mission operation'
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def initialize(opts = {}, &operation)
|
|
177
|
+
@mutex = Mutex.new
|
|
178
|
+
raise Unavailable, 'mission containment requires Linux' unless RUBY_PLATFORM.include?('linux')
|
|
179
|
+
|
|
180
|
+
if self.class.prepared? && !self.class.force_exec && !(opts[:inherit_namespace] && self.class.owned)
|
|
181
|
+
start_prepared(opts, &operation)
|
|
182
|
+
return
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
if operation
|
|
186
|
+
raise Unavailable, 'host closures cannot cross an exec boundary; use a mission descriptor or explicitly bootstrap under unshare --user --map-current-user --keep-caps' unless self.class.owned
|
|
187
|
+
|
|
188
|
+
@pid = fork do
|
|
189
|
+
Thread.handle_interrupt(Exception => :immediate) { operation.call }
|
|
190
|
+
ensure
|
|
191
|
+
exit!
|
|
192
|
+
end
|
|
193
|
+
@pidfd = IO.new(self.class.checked(self.class.native('pidfd_open', [Fiddle::TYPE_INT, Fiddle::TYPE_INT]).call(@pid, 0), 'pidfd_open'))
|
|
194
|
+
return
|
|
195
|
+
end
|
|
196
|
+
snapshot = { requires: Array(self.class.bootstrap_requires), descriptor: opts.fetch(:descriptor), env: PWN::Env, locals: CONTEXT_KEYS.to_h { |key| [key, Thread.current[key]] } }
|
|
197
|
+
if PWN.const_defined?(:Skills)
|
|
198
|
+
snapshot[:skills] = PWN::Skills
|
|
199
|
+
ruby_skills = PWN::Skills.values.select { |skill| skill[:loaded] && skill[:type] == :ruby }
|
|
200
|
+
snapshot[:requires] += ruby_skills.flat_map do |skill|
|
|
201
|
+
skill[:format] == :legacy ? [skill.fetch(:path)] : Dir.glob(File.join(skill.fetch(:dir), 'scripts', '*.rb'))
|
|
202
|
+
end
|
|
203
|
+
snapshot[:requires].uniq!
|
|
204
|
+
end
|
|
205
|
+
payload = JSON.generate(self.class.encode(snapshot))
|
|
206
|
+
@channel, child = UNIXSocket.pair
|
|
207
|
+
mapping = opts.fetch(:fds, {}).merge(3 => child)
|
|
208
|
+
@pid = Process.spawn('unshare', '--user', '--map-current-user', '--keep-caps', RbConfig.ruby, '-I', $LOAD_PATH.join(File::PATH_SEPARATOR), '-rpwn', '-e', 'exit!(PWN::AI::Agent::MissionNamespace.supervise || 1)', mapping.merge(close_others: true))
|
|
209
|
+
child.close
|
|
210
|
+
Timeout.timeout(15) do
|
|
211
|
+
@channel.puts(payload)
|
|
212
|
+
@pidfd = @channel.recv_io
|
|
213
|
+
@channel.write('1')
|
|
214
|
+
end
|
|
215
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- failed startup cannot leak a child
|
|
216
|
+
close
|
|
217
|
+
raise Unavailable, "mission not started: #{e.message}; install util-linux unshare and enable Linux user/PID namespaces and pidfds"
|
|
218
|
+
ensure
|
|
219
|
+
child&.close unless child&.closed?
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def wait
|
|
223
|
+
@pidfd.wait_readable if @pidfd && !@pidfd.closed?
|
|
224
|
+
@mutex.synchronize { reap }
|
|
225
|
+
rescue IOError, Errno::EBADF
|
|
226
|
+
@mutex.synchronize { reap }
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def close
|
|
230
|
+
return :reaped unless @mutex
|
|
231
|
+
|
|
232
|
+
@mutex.synchronize do
|
|
233
|
+
if @pidfd && !@pidfd.closed?
|
|
234
|
+
result = self.class.native('pidfd_send_signal', [Fiddle::TYPE_INT, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT]).call(@pidfd.fileno, Signal.list.fetch('KILL'), 0, 0)
|
|
235
|
+
self.class.checked(result, 'pidfd_send_signal') unless result.negative? && Fiddle.last_error == Errno::ESRCH::Errno
|
|
236
|
+
elsif @pid
|
|
237
|
+
begin
|
|
238
|
+
Process.kill('KILL', @pid)
|
|
239
|
+
rescue Errno::ESRCH
|
|
240
|
+
nil
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
reap
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
public_class_method def self.authors
|
|
248
|
+
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
public_class_method def self.help
|
|
252
|
+
puts <<~HELP
|
|
253
|
+
# Identify module authors.
|
|
254
|
+
#{self}.authors
|
|
255
|
+
|
|
256
|
+
# Launch a private Linux mission and retain its cancellation owner.
|
|
257
|
+
#{self}.new(
|
|
258
|
+
descriptor: 'required - serializable operation and request parameters',
|
|
259
|
+
fds: 'optional - explicitly inherited descriptor map'
|
|
260
|
+
)
|
|
261
|
+
HELP
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
private
|
|
265
|
+
|
|
266
|
+
def start_prepared(opts, &operation)
|
|
267
|
+
ready, announce = IO.pipe
|
|
268
|
+
release, gate = IO.pipe
|
|
269
|
+
owner = Process.pid
|
|
270
|
+
@pid = fork do
|
|
271
|
+
ready.close
|
|
272
|
+
gate.close
|
|
273
|
+
begin
|
|
274
|
+
self.class.checked(self.class.native('prctl', [Fiddle::TYPE_INT, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG]).call(1, Signal.list.fetch('KILL'), 0, 0, 0), 'PR_SET_PDEATHSIG')
|
|
275
|
+
exit! unless Process.ppid == owner
|
|
276
|
+
self.class.checked(self.class.native('unshare', [Fiddle::TYPE_INT]).call(0x20000000), 'unshare PID namespace')
|
|
277
|
+
armed, arm = IO.pipe
|
|
278
|
+
init = fork do
|
|
279
|
+
armed.close
|
|
280
|
+
announce.close
|
|
281
|
+
self.class.checked(self.class.native('prctl', [Fiddle::TYPE_INT, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG]).call(1, Signal.list.fetch('KILL'), 0, 0, 0), 'PR_SET_PDEATHSIG')
|
|
282
|
+
arm.write('1')
|
|
283
|
+
arm.close
|
|
284
|
+
exit! unless release.read(1) == '1'
|
|
285
|
+
release.close
|
|
286
|
+
self.class.owned = true
|
|
287
|
+
Thread.current[:pwn_mission_fds] = opts.fetch(:fds, {})
|
|
288
|
+
begin
|
|
289
|
+
Thread.handle_interrupt(Exception => :immediate) { operation ? operation.call : self.class.execute(opts.fetch(:descriptor)) }
|
|
290
|
+
completed = true
|
|
291
|
+
ensure
|
|
292
|
+
exit!(completed ? 0 : 1)
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
arm.close
|
|
296
|
+
raise Unavailable, 'namespace init exited before arming parent-death protection' unless armed.read(1) == '1'
|
|
297
|
+
|
|
298
|
+
armed.close
|
|
299
|
+
announce.puts(JSON.generate(pid: init))
|
|
300
|
+
announce.close
|
|
301
|
+
release.close
|
|
302
|
+
_pid, status = Process.wait2(init)
|
|
303
|
+
exit!(status.exitstatus || (128 + status.termsig))
|
|
304
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- report child startup errors
|
|
305
|
+
announce.puts(JSON.generate(error: e.message)) unless announce.closed?
|
|
306
|
+
ensure
|
|
307
|
+
exit!
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
announce.close
|
|
311
|
+
release.close
|
|
312
|
+
message = JSON.parse(ready.gets || '{}')
|
|
313
|
+
raise Unavailable, message.fetch('error', 'namespace supervisor exited') unless message['pid']
|
|
314
|
+
|
|
315
|
+
@pidfd = IO.new(self.class.checked(self.class.native('pidfd_open', [Fiddle::TYPE_INT, Fiddle::TYPE_INT]).call(message.fetch('pid'), 0), 'pidfd_open'))
|
|
316
|
+
gate.write('1')
|
|
317
|
+
ensure
|
|
318
|
+
[ready, announce, release, gate].compact.each { |io| io.close unless io.closed? }
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def reap
|
|
322
|
+
_pid, @status = Process.wait2(@pid) if @pid
|
|
323
|
+
@pid = nil
|
|
324
|
+
@channel&.close unless @channel&.closed?
|
|
325
|
+
@pidfd&.close unless @pidfd&.closed?
|
|
326
|
+
:reaped
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
data/lib/pwn/ai/agent/policy.rb
CHANGED
|
@@ -4,6 +4,7 @@ require 'json'
|
|
|
4
4
|
require 'fileutils'
|
|
5
5
|
require 'time'
|
|
6
6
|
require 'digest'
|
|
7
|
+
require_relative '../../memory'
|
|
7
8
|
|
|
8
9
|
module PWN
|
|
9
10
|
module AI
|
|
@@ -696,6 +697,8 @@ module PWN
|
|
|
696
697
|
|
|
697
698
|
public_class_method def self.lean!(opts = {})
|
|
698
699
|
dry = opts[:dry_run] ? true : false
|
|
700
|
+
return PWN::Memory.transaction(path: TRAJECTORY_FILE) { lean!(opts) } unless dry || PWN::Memory.transaction_owned?(path: TRAJECTORY_FILE)
|
|
701
|
+
|
|
699
702
|
return { skipped: true } unless File.exist?(TRAJECTORY_FILE)
|
|
700
703
|
|
|
701
704
|
rows = File.readlines(TRAJECTORY_FILE)
|
|
@@ -1385,6 +1388,8 @@ module PWN
|
|
|
1385
1388
|
end
|
|
1386
1389
|
|
|
1387
1390
|
private_class_method def self.persist_episode!(opts = {})
|
|
1391
|
+
return PWN::Memory.transaction(path: TRAJECTORY_FILE) { persist_episode!(opts) } unless PWN::Memory.transaction_owned?(path: TRAJECTORY_FILE)
|
|
1392
|
+
|
|
1388
1393
|
ep = opts[:episode]
|
|
1389
1394
|
return unless ep.is_a?(Hash)
|
|
1390
1395
|
|
|
@@ -59,6 +59,8 @@ module PWN
|
|
|
59
59
|
# Explicit operator action only; never called from Policy.finish/Loop.
|
|
60
60
|
public_class_method def self.promote(opts = {})
|
|
61
61
|
return { promoted: false, reason: 'disabled' } unless opts[:enabled] == true
|
|
62
|
+
return PWN::Backups.transaction { promote(opts) } unless PWN::Backups.active?
|
|
63
|
+
|
|
62
64
|
raise ArgumentError, 'stop all policy writers and set quiescent: true' unless opts[:quiescent] == true
|
|
63
65
|
|
|
64
66
|
baseline = read_snapshot(path: opts.fetch(:baseline))
|
|
@@ -80,12 +82,7 @@ module PWN
|
|
|
80
82
|
raise ArgumentError, 'snapshot changed during evaluation' unless replayed.all? { |r| r[:snapshot_sha256] == { baseline: prior_digest, candidate: candidate_digest } }
|
|
81
83
|
raise ArgumentError, 'live policy is not the evaluated baseline' unless read_snapshot(path: live) == baseline
|
|
82
84
|
|
|
83
|
-
backup =
|
|
84
|
-
if File.exist?(backup)
|
|
85
|
-
raise ArgumentError, 'rollback snapshot differs' unless read_snapshot(path: backup) == baseline
|
|
86
|
-
else
|
|
87
|
-
File.open(backup, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |file| file.write(baseline) }
|
|
88
|
-
end
|
|
85
|
+
backup = PWN::Backups.capture(path: live, label: 'policy-evaluation', pin: true)
|
|
89
86
|
replace(path: live, bytes: candidate)
|
|
90
87
|
{ promoted: true, receipt_version: 1, change_kind: 'routing_policy',
|
|
91
88
|
acceptance_sha256: Digest::SHA256.hexdigest(JSON.generate(deterministic(value: replayed))),
|
|
@@ -97,6 +94,8 @@ module PWN
|
|
|
97
94
|
|
|
98
95
|
public_class_method def self.rollback(opts = {})
|
|
99
96
|
return { rolled_back: false, reason: 'disabled' } unless opts[:enabled] == true
|
|
97
|
+
return PWN::Backups.transaction { rollback(opts) } unless PWN::Backups.active?
|
|
98
|
+
|
|
100
99
|
raise ArgumentError, 'stop all policy writers and set quiescent: true' unless opts[:quiescent] == true
|
|
101
100
|
|
|
102
101
|
receipt = opts.fetch(:receipt)
|
|
@@ -106,8 +105,9 @@ module PWN
|
|
|
106
105
|
prior_digest = receipt.fetch(:baseline_sha256)
|
|
107
106
|
raise ArgumentError, 'invalid rollback digest' unless prior_digest.is_a?(String) && prior_digest.match?(/\A[0-9a-f]{64}\z/)
|
|
108
107
|
|
|
109
|
-
backup =
|
|
110
|
-
|
|
108
|
+
backup = File.expand_path(receipt.fetch(:rollback_path))
|
|
109
|
+
root = Thread.current[:pwn_backup_transaction][:root]
|
|
110
|
+
raise ArgumentError, 'rollback target mismatch' unless receipt[:live_path] == live && backup.start_with?("#{root}/") && File.realpath(backup) == backup
|
|
111
111
|
|
|
112
112
|
bytes = read_snapshot(path: backup)
|
|
113
113
|
raise ArgumentError, 'rollback snapshot digest mismatch' unless Digest::SHA256.hexdigest(bytes) == prior_digest
|