ace-assign 0.63.1 → 0.66.0

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 (147) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
  3. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
  4. data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
  5. data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
  6. data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
  7. data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
  8. data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
  9. data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
  10. data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
  11. data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
  12. data/CHANGELOG.md +62 -0
  13. data/docs/demo/canonical-skill-source.tape.yml +4 -4
  14. data/docs/protected-authority.md +282 -0
  15. data/docs/usage.md +205 -3
  16. data/exe/ace-scope-ready +55 -0
  17. data/handbook/workflow-instructions/assign/drive.wf.md +28 -3
  18. data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
  19. data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
  20. data/lib/ace/assign/atoms/delivery_parameters.rb +19 -0
  21. data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
  22. data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
  23. data/lib/ace/assign/authority/campaign_execution.rb +119 -0
  24. data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
  25. data/lib/ace/assign/authority/candidate_transfer.rb +327 -0
  26. data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
  27. data/lib/ace/assign/authority/client.rb +257 -0
  28. data/lib/ace/assign/authority/deployment.rb +627 -0
  29. data/lib/ace/assign/authority/deployment_history.rb +146 -0
  30. data/lib/ace/assign/authority/endcap.rb +455 -0
  31. data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
  32. data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
  33. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
  34. data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
  35. data/lib/ace/assign/authority/endcap_inboxes.rb +37 -0
  36. data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
  37. data/lib/ace/assign/authority/endcap_publication.rb +207 -0
  38. data/lib/ace/assign/authority/endcap_results.rb +398 -0
  39. data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
  40. data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
  41. data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
  42. data/lib/ace/assign/authority/endcap_services.rb +598 -0
  43. data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
  44. data/lib/ace/assign/authority/execution_scope_observation.rb +903 -0
  45. data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
  46. data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
  47. data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
  48. data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
  49. data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
  50. data/lib/ace/assign/authority/launch_driver.rb +393 -0
  51. data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
  52. data/lib/ace/assign/authority/launch_lifecycle.rb +997 -0
  53. data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
  54. data/lib/ace/assign/authority/launch_review.rb +91 -0
  55. data/lib/ace/assign/authority/launch_scope_abort.rb +81 -0
  56. data/lib/ace/assign/authority/launch_scope_admission.rb +215 -0
  57. data/lib/ace/assign/authority/launch_scope_close.rb +146 -0
  58. data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
  59. data/lib/ace/assign/authority/launch_scope_release.rb +859 -0
  60. data/lib/ace/assign/authority/launch_steering.rb +353 -0
  61. data/lib/ace/assign/authority/launch_stop.rb +254 -0
  62. data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
  63. data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
  64. data/lib/ace/assign/authority/posix_acl.rb +59 -0
  65. data/lib/ace/assign/authority/prepared_input.rb +130 -0
  66. data/lib/ace/assign/authority/prepared_queue.rb +185 -0
  67. data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
  68. data/lib/ace/assign/authority/prepared_work.rb +304 -0
  69. data/lib/ace/assign/authority/prepared_worker.rb +69 -0
  70. data/lib/ace/assign/authority/private_directory.rb +33 -0
  71. data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
  72. data/lib/ace/assign/authority/readiness_hook.rb +120 -0
  73. data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
  74. data/lib/ace/assign/authority/router.rb +76 -0
  75. data/lib/ace/assign/authority/server.rb +300 -0
  76. data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
  77. data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
  78. data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
  79. data/lib/ace/assign/authority/service_evidence.rb +286 -0
  80. data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
  81. data/lib/ace/assign/authority/task_context_entry.rb +32 -0
  82. data/lib/ace/assign/authority/transfer_codec.rb +187 -0
  83. data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
  84. data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
  85. data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
  86. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
  87. data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
  88. data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
  89. data/lib/ace/assign/cli/commands/authority/launch.rb +97 -0
  90. data/lib/ace/assign/cli/commands/authority/serve.rb +31 -0
  91. data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
  92. data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
  93. data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
  94. data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
  95. data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
  96. data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
  97. data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
  98. data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
  99. data/lib/ace/assign/cli/commands/create.rb +11 -1
  100. data/lib/ace/assign/cli/commands/delivery.rb +107 -0
  101. data/lib/ace/assign/cli/commands/fail.rb +6 -0
  102. data/lib/ace/assign/cli/commands/finish.rb +23 -2
  103. data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
  104. data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
  105. data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
  106. data/lib/ace/assign/cli/commands/resume.rb +38 -0
  107. data/lib/ace/assign/cli/commands/select.rb +2 -0
  108. data/lib/ace/assign/cli/commands/start.rb +2 -0
  109. data/lib/ace/assign/cli/commands/status.rb +4 -2
  110. data/lib/ace/assign/cli/commands/step.rb +3 -1
  111. data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
  112. data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
  113. data/lib/ace/assign/cli.rb +56 -4
  114. data/lib/ace/assign/errors.rb +139 -0
  115. data/lib/ace/assign/models/assignment.rb +26 -4
  116. data/lib/ace/assign/models/evidence_event.rb +4 -2
  117. data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
  118. data/lib/ace/assign/molecules/attempt_reconciler.rb +51 -12
  119. data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
  120. data/lib/ace/assign/molecules/canonical_evidence.rb +153 -0
  121. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
  122. data/lib/ace/assign/molecules/evidence_journal.rb +922 -91
  123. data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
  124. data/lib/ace/assign/molecules/execution_scope_lineage.rb +345 -0
  125. data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
  126. data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
  127. data/lib/ace/assign/molecules/journal_mutation.rb +271 -0
  128. data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
  129. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +182 -26
  130. data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
  131. data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
  132. data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
  133. data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
  134. data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
  135. data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
  136. data/lib/ace/assign/molecules/receipt_verifier.rb +68 -32
  137. data/lib/ace/assign/molecules/step_writer.rb +27 -5
  138. data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
  139. data/lib/ace/assign/organisms/assignment_executor.rb +10 -4
  140. data/lib/ace/assign/organisms/attempt_coordinator.rb +302 -13
  141. data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
  142. data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
  143. data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
  144. data/lib/ace/assign/organisms/task_assignment_creator.rb +11 -4
  145. data/lib/ace/assign/version.rb +1 -1
  146. data/lib/ace/assign.rb +4 -102
  147. metadata +186 -8
@@ -0,0 +1,327 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "fileutils"
6
+ require "tmpdir"
7
+ require "tempfile"
8
+ require "ace/herdr/molecules/bounded_process"
9
+ require_relative "private_directory"
10
+
11
+ module Ace
12
+ module Assign
13
+ module Authority
14
+ # Untrusted bundles enter a clean bare quarantine. Only complete verified
15
+ # objects are retained; no uploader configuration or worktree is read.
16
+ class CandidateTransfer
17
+ MAX_BYTES = 64 * 1024 * 1024
18
+ DEADLINE = 30
19
+ SHA = /\A[0-9a-f]{40}\z/
20
+
21
+ class CampaignReadView
22
+ def initialize(owner:, repository:, base:, deadline:)
23
+ @owner, @repository, @base, @deadline = owner, repository, base, deadline
24
+ @thread, @active = Thread.current, true
25
+ end
26
+
27
+ def close = @active = false
28
+ def head = command("rev-parse", "HEAD").strip
29
+ def base
30
+ commit!(@base)
31
+ @base
32
+ end
33
+ def commit!(revision)
34
+ raise ArgumentError, "campaign revision is invalid" unless revision.is_a?(String) && revision.match?(SHA)
35
+ command("cat-file", "-e", "#{revision}^{commit}")
36
+ true
37
+ end
38
+ def full_diff?(base, head)
39
+ commit!(base)
40
+ commit!(head)
41
+ !command("diff", "--raw", "--no-ext-diff", "--no-textconv", base, head, "--").empty?
42
+ end
43
+ def clean?
44
+ command("status", "--porcelain", "-z", "--untracked-files=all").split("\0")
45
+ .all? { |entry| entry.start_with?("?? .ace-local/") }
46
+ end
47
+
48
+ private
49
+
50
+ def command(*arguments)
51
+ unless @active && Thread.current.equal?(@thread)
52
+ raise AttemptErrors::EvidenceUnavailable, "campaign Git read view escaped its source lifetime"
53
+ end
54
+ @owner.send(:git, @repository, @deadline, *arguments)
55
+ rescue Timeout::Error
56
+ raise AttemptErrors::EvidenceUnavailable, "campaign Git read deadline expired"
57
+ end
58
+ end
59
+
60
+ def with_campaign_repository(bytes:, sha256:, size:, head:, tree:, base:)
61
+ raise ArgumentError, "campaign repository requires a block" unless block_given?
62
+ deadline = monotonic + DEADLINE
63
+ materialized = materialize_repository(bytes: bytes, sha256: sha256, size: size, head: head, tree: tree, root: @root)
64
+ directory = materialized.fetch("directory")
65
+ reader = CampaignReadView.new(owner: self, repository: directory, base: base, deadline: deadline)
66
+ unless reader.head == head
67
+ raise AttemptErrors::EvidenceUnavailable, "campaign Git head differs from original candidate"
68
+ end
69
+ reader.base
70
+ yield reader
71
+ ensure
72
+ reader&.close
73
+ FileUtils.remove_entry(directory) if directory && File.directory?(directory)
74
+ end
75
+
76
+ def initialize(root:)
77
+ @root = File.expand_path(root)
78
+ verify_root!
79
+ end
80
+
81
+ # Export the already validated prepared tree using the same isolated Git
82
+ # boundary as admission. The returned bytes are retained by the producer;
83
+ # registration must not rebuild them after a lost response.
84
+ def export_prepared(work:)
85
+ require_relative "prepared_work"
86
+ reject!("Prepared export requires validated work") unless work.is_a?(PreparedWork)
87
+ verify_root!
88
+ deadline = monotonic + DEADLINE
89
+ Dir.mktmpdir("prepared-export-", @root) do |directory|
90
+ File.chmod(0700, directory)
91
+ repository = File.join(directory, "repository")
92
+ git(directory, deadline, "init", "--template=", "--initial-branch=prepared", repository)
93
+ work.files.each do |path, bytes|
94
+ target = File.join(repository, path)
95
+ FileUtils.mkdir_p(File.dirname(target), mode: 0700)
96
+ File.open(target, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0600) do |file|
97
+ file.write(bytes)
98
+ end
99
+ end
100
+ git(repository, deadline, "add", "--", ".")
101
+ git(repository, deadline, "-c", "user.name=ACE prepared input", "-c", "user.email=prepared@ace.invalid",
102
+ "-c", "commit.gpgSign=false", "commit", "--no-verify", "-m", "Prepared assignment input")
103
+ head = git(repository, deadline, "rev-parse", "HEAD").strip
104
+ tree = git(repository, deadline, "rev-parse", "HEAD^{tree}").strip
105
+ bundle = File.join(directory, "prepared.bundle")
106
+ git(repository, deadline, "bundle", "create", bundle, "refs/heads/prepared")
107
+ bytes = read_bundle(bundle).freeze
108
+ {"head" => head.freeze, "tree" => tree.freeze, "bundle" => bytes,
109
+ "bytes" => bytes.bytesize, "sha256" => Digest::SHA256.hexdigest(bytes).freeze,
110
+ "definition_bytes" => work.definition_bytes(head: head, tree: tree)}.freeze
111
+ end
112
+ rescue Timeout::Error
113
+ reject!("Prepared export deadline exceeded")
114
+ rescue SystemCallError
115
+ reject!("Prepared export filesystem is unavailable")
116
+ end
117
+
118
+ def admit(bytes:, sha256:, size:, head:, &consumer)
119
+ unless bytes.is_a?(String) && size.is_a?(Integer) && size.positive? && size <= MAX_BYTES &&
120
+ size == bytes.bytesize && sha256.is_a?(String) &&
121
+ Digest::SHA256.hexdigest(bytes.b) == sha256 && head.is_a?(String) && head.match?(SHA)
122
+ reject!("Candidate bundle binding or size is invalid")
123
+ end
124
+ verify_root!
125
+ deadline = monotonic + DEADLINE
126
+ Dir.mktmpdir("quarantine-", @root) do |directory|
127
+ File.chmod(0700, directory)
128
+ bundle = File.join(directory, "candidate.bundle")
129
+ File.open(bundle, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0600) { |f| f.write(bytes.b) }
130
+ repository = File.join(directory, "repository")
131
+ git(directory, deadline, "init", "--bare", "--template=", repository)
132
+ # Verification against an empty repository rejects incremental
133
+ # bundles requiring any caller-supplied prerequisites.
134
+ git(repository, deadline, "bundle", "verify", bundle)
135
+ advertised = git(repository, deadline, "bundle", "list-heads", bundle).lines.map { |line| line.split.first }
136
+ reject!("Candidate head is not advertised") unless advertised.include?(head)
137
+ git(repository, deadline, "fetch", "--no-tags", bundle, "#{head}:refs/heads/candidate")
138
+ actual = git(repository, deadline, "rev-parse", "refs/heads/candidate^{commit}").strip
139
+ reject!("Candidate commit differs from declared head") unless actual == head
140
+ git(repository, deadline, "fsck", "--full", "--strict", "--no-reflogs")
141
+ tree = git(repository, deadline, "rev-parse", "#{head}^{tree}").strip
142
+ exported = File.join(directory, "verified.bundle")
143
+ git(repository, deadline, "bundle", "create", exported, "refs/heads/candidate")
144
+ verified = read_bundle(exported)
145
+ # The journal owns candidate identity/generation. This object is
146
+ # only immutable transport bytes, not another authority ledger.
147
+ result = {"head" => head, "tree" => tree, "bundle" => verified,
148
+ "sha256" => Digest::SHA256.hexdigest(verified), "bytes" => verified.bytesize}
149
+ consumer ? consumer.call(repository, deadline, result) : result
150
+ end
151
+ rescue Timeout::Error
152
+ reject!("Candidate transfer deadline exceeded")
153
+ rescue SystemCallError
154
+ reject!("Candidate transfer filesystem is unavailable")
155
+ end
156
+
157
+ # root is selected from installed peer credentials by the receiver,
158
+ # never a request field. Materialization reads objects rather than Git
159
+ # checkout, so .gitattributes, filters and uploader hooks cannot run.
160
+ def materialize(bytes:, sha256:, size:, head:, tree:, root:)
161
+ self.class.new(root: root)
162
+ admit(bytes: bytes, sha256: sha256, size: size, head: head) do |repository, deadline, result|
163
+ reject!("Candidate tree differs from accepted tree") unless result["tree"] == tree
164
+ destination = Dir.mktmpdir("candidate-", root)
165
+ File.chmod(0700, destination)
166
+ begin
167
+ entries = git(repository, deadline, "ls-tree", "-r", "-z", head, limit: MAX_BYTES).b.split("\0")
168
+ total = 0
169
+ links = []
170
+ entries.each do |entry|
171
+ metadata, path = entry.split("\t", 2)
172
+ mode, kind, oid = metadata.to_s.split(" ")
173
+ parts = path.to_s.split("/", -1)
174
+ unless path && !path.start_with?("/") &&
175
+ parts.none? { |part| part.empty? || %w[. .. .git].include?(part.downcase) } &&
176
+ kind == "blob" && %w[100644 100755 120000].include?(mode) && oid.match?(SHA)
177
+ reject!("Candidate contains unsafe filesystem entry")
178
+ end
179
+ content = git(repository, deadline, "cat-file", "blob", oid, limit: MAX_BYTES).b
180
+ total += content.bytesize
181
+ reject!("Candidate expanded content is oversized") if total > MAX_BYTES
182
+ target = File.join(destination, path)
183
+ FileUtils.mkdir_p(File.dirname(target), mode: 0700)
184
+ if mode == "120000"
185
+ resolved = File.expand_path(content, File.dirname(target))
186
+ unless !content.include?("\0") && !content.start_with?("/") &&
187
+ resolved.start_with?(destination + "/")
188
+ reject!("Candidate symlink escapes private checkout")
189
+ end
190
+ links << [target, content]
191
+ else
192
+ File.open(target, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW,
193
+ mode == "100755" ? 0700 : 0600) { |file| file.write(content) }
194
+ end
195
+ yield(path.freeze, mode.freeze, oid.freeze, content.freeze) if block_given?
196
+ end
197
+ links.each { |target, content| File.symlink(content, target) }
198
+ links.each do |target, _content|
199
+ resolved = File.realpath(target)
200
+ reject!("Candidate symlink escapes private checkout") unless resolved.start_with?(destination + "/")
201
+ end
202
+ result.reject { |key, _value| key == "bundle" }.merge("directory" => destination)
203
+ rescue Exception
204
+ # Only this freshly created private disposable directory is
205
+ # removed. An existing peer scratch tree is never cleaned.
206
+ FileUtils.rm_rf(destination)
207
+ raise
208
+ end
209
+ end
210
+ rescue SystemCallError, ArgumentError
211
+ reject!("Candidate filesystem materialization is unverifiable")
212
+ end
213
+
214
+ # Executing campaign children need local Git history as well as files.
215
+ # Create only our own empty administration; no uploader configuration,
216
+ # checkout filters or hooks enter this private repository.
217
+ def materialize_repository(bytes:, sha256:, size:, head:, tree:, root:)
218
+ deadline = monotonic + DEADLINE
219
+ result = materialize(bytes: bytes, sha256: sha256, size: size, head: head, tree: tree, root: root)
220
+ directory = result.fetch("directory")
221
+ Tempfile.create(["campaign-candidate-", ".bundle"], root) do |file|
222
+ file.binmode
223
+ file.write(bytes); file.flush
224
+ git(directory, deadline, "init", "--template=", "--initial-branch=work")
225
+ git(directory, deadline, "fetch", "--no-tags", file.path, "#{head}:refs/heads/candidate")
226
+ git(directory, deadline, "symbolic-ref", "HEAD", "refs/heads/candidate")
227
+ git(directory, deadline, "read-tree", head)
228
+ reject!("Campaign private repository differs") unless git(directory, deadline, "rev-parse", "HEAD^{tree}").strip == tree
229
+ end
230
+ result
231
+ rescue Exception
232
+ FileUtils.remove_entry(directory) if directory && File.directory?(directory) && !File.symlink?(directory)
233
+ raise
234
+ end
235
+
236
+ # Full immutable review input comes from the same admitted Git objects
237
+ # used for materialization, never a second read of the scratch checkout.
238
+ def review_snapshot(bytes:, sha256:, size:, head:, tree:, root:)
239
+ subject = JSON.generate("schema" => "ace.candidate-review-snapshot/v1", "head" => head, "tree" => tree)
240
+ .delete_suffix("}") + ',"files":['
241
+ first = true
242
+ result = materialize(bytes: bytes, sha256: sha256, size: size, head: head, tree: tree, root: root) do |path, mode, oid, content|
243
+ entry = JSON.generate("path" => review_bytes(path), "mode" => mode, "oid" => oid, "content" => review_bytes(content))
244
+ separator = first ? "" : ","
245
+ reject!("Complete candidate review snapshot is oversized") if subject.bytesize + separator.bytesize + entry.bytesize + 2 > MAX_BYTES
246
+ subject << separator << entry
247
+ first = false
248
+ end
249
+ subject << "]}"
250
+ result.merge("subject" => subject.freeze)
251
+ end
252
+
253
+ # Prepared instructions have a narrower tree contract than candidates:
254
+ # no executable entries or symlinks, and bounded UTF-8 files. Read only
255
+ # verified Git objects; never run checkout filters or reopen uploader paths.
256
+ def prepared_files(repository:, deadline:, head:)
257
+ entries = git(repository, deadline, "ls-tree", "-r", "-z", head, limit: MAX_BYTES).b.split("\0")
258
+ reject!("Prepared file inventory is oversized") unless entries.size.between?(4, 4096)
259
+ total = 0
260
+ files = entries.to_h do |entry|
261
+ metadata, path = entry.split("\t", 2)
262
+ mode, kind, oid = metadata.to_s.split(" ")
263
+ unless path && mode == "100644" && kind == "blob" && oid.match?(SHA) &&
264
+ !path.start_with?("/") && path.split("/", -1).none? { |part| part.empty? || %w[. .. .git].include?(part.downcase) }
265
+ reject!("Prepared tree contains unsafe entry")
266
+ end
267
+ size = git(repository, deadline, "cat-file", "-s", oid).strip
268
+ reject!("Prepared text is oversized") unless size.match?(/\A[0-9]+\z/) && size.to_i.between?(1, 1024 * 1024)
269
+ total += size.to_i
270
+ reject!("Prepared expanded content is oversized") if total > MAX_BYTES
271
+ [path, git(repository, deadline, "cat-file", "blob", oid, limit: 1024 * 1024).b]
272
+ end
273
+ reject!("Prepared inventory has duplicate paths") unless files.size == entries.size
274
+ files
275
+ end
276
+
277
+ private
278
+
279
+ def review_bytes(bytes)
280
+ text = bytes.dup.force_encoding(Encoding::UTF_8)
281
+ if text.valid_encoding? && !text.include?("\0")
282
+ {"encoding" => "utf-8", "bytes" => text}
283
+ else
284
+ {"encoding" => "base64", "bytes" => [bytes].pack("m0")}
285
+ end
286
+ end
287
+
288
+ def verify_root!
289
+ PrivateDirectory.verify!(@root)
290
+ end
291
+
292
+ def git(repository, deadline, *arguments, limit: 65_536)
293
+ remaining = deadline - monotonic
294
+ raise Timeout::Error if remaining <= 0
295
+ # Git variables may redirect object/config storage even in a clean
296
+ # repository. Strip every inherited Git setting and global config.
297
+ environment = ENV.keys.to_h { |key| [key, nil] }
298
+ environment.merge!("GIT_CONFIG_NOSYSTEM" => "1", "GIT_CONFIG_GLOBAL" => "/dev/null",
299
+ "GIT_TERMINAL_PROMPT" => "0", "GIT_ALLOW_PROTOCOL" => "file",
300
+ "GIT_NO_REPLACE_OBJECTS" => "1", "PATH" => "/usr/bin:/bin", "LC_ALL" => "C")
301
+ result = Herdr::Molecules::BoundedProcess.call([environment, "/usr/bin/git", "-C", repository,
302
+ "-c", "core.hooksPath=/dev/null", "-c", "protocol.file.allow=always", *arguments],
303
+ stdin_data: "", timeout_s: remaining, output_limit: limit)
304
+ reject!("Candidate Git verification failed") unless result.status.success? && !result.oversized
305
+ result.stdout
306
+ end
307
+
308
+ def read_bundle(path)
309
+ File.open(path, File::RDONLY | File::NOFOLLOW) do |file|
310
+ reject!("Verified bundle is oversized") unless file.stat.file? && file.stat.size <= MAX_BYTES
311
+ bytes = file.read(MAX_BYTES + 1).b
312
+ reject!("Verified bundle is oversized") if bytes.bytesize > MAX_BYTES
313
+ bytes
314
+ end
315
+ end
316
+
317
+ def monotonic
318
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
319
+ end
320
+
321
+ def reject!(message)
322
+ raise AttemptErrors::ReceiptRejected, message
323
+ end
324
+ end
325
+ end
326
+ end
327
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fiddle"
4
+ require "ace/runtime/molecules/protected_artifact_set"
5
+
6
+ module Ace
7
+ module Assign
8
+ module Authority
9
+ # Extends the existing held artifact protection solely for the bounded
10
+ # root result view. Private archive storage is never admitted here.
11
+ class CleanupResultProtection < Ace::Runtime::Molecules::ProtectedArtifactSet::Protection
12
+ class LinuxACL
13
+ def absent!(handle, attribute)
14
+ unless RUBY_PLATFORM.include?("linux")
15
+ raise Ace::Runtime::RuntimeUnavailableError, "cleanup result ACL evidence requires Linux"
16
+ end
17
+ function = Fiddle::Function.new(Fiddle::Handle::DEFAULT["fgetxattr"],
18
+ [Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T], Fiddle::TYPE_SSIZE_T)
19
+ size = function.call(handle.fileno, attribute, 0, 0)
20
+ error = Fiddle.last_error
21
+ unless size == -1 && error == Errno::ENODATA::Errno
22
+ raise Ace::Runtime::RuntimeUnavailableError, "cleanup result ACL protection is unavailable"
23
+ end
24
+ rescue Fiddle::DLError
25
+ raise Ace::Runtime::RuntimeUnavailableError, "cleanup result ACL reader is unavailable"
26
+ end
27
+ end
28
+
29
+ def initialize(result_path:, authority_gid:, acl: LinuxACL.new, **options)
30
+ unless result_path.is_a?(String) && result_path.start_with?("/") && !result_path.include?("\0") &&
31
+ File.expand_path(result_path) == result_path && authority_gid.is_a?(Integer) && authority_gid.positive?
32
+ raise ArgumentError, "invalid fixed cleanup result view"
33
+ end
34
+ super(**options)
35
+ @result_path, @parent, @authority_gid, @acl = result_path.dup.freeze, File.dirname(result_path).freeze, authority_gid, acl
36
+ end
37
+
38
+ def verify!(path, handle, directory:)
39
+ super
40
+ return unless path == @result_path || path == @parent
41
+ parent = path == @parent
42
+ stat = handle.stat
43
+ unless directory == parent && stat.gid == @authority_gid &&
44
+ (stat.mode & 0o7777) == (parent ? 0o2750 : 0o640)
45
+ raise Ace::Runtime::RuntimeUnavailableError, "cleanup result view mode or group differs"
46
+ end
47
+ @acl.absent!(handle, "system.posix_acl_access")
48
+ @acl.absent!(handle, "system.posix_acl_default") if parent
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+ require "securerandom"
3
+ require_relative "deployment"
4
+ require_relative "transfer_codec"
5
+ require_relative "campaign_execution"
6
+ require_relative "candidate_transfer"
7
+ require_relative "../molecules/canonical_evidence"
8
+ require_relative "../molecules/execution_scope_lineage"
9
+
10
+ module Ace
11
+ module Assign
12
+ module Authority
13
+ class Client
14
+ Reply = Struct.new(:data, :replayed, :parts, keyword_init: true)
15
+ attr_reader :mapping_id
16
+ def initialize(mapping_id:, deployment: Deployment.load, kernel: Ace::Runtime::Molecules::ProtectedLinux.new)
17
+ @mapping_id, @deployment, @kernel = mapping_id, deployment, kernel
18
+ @map = deployment.mapping(mapping_id)
19
+ @service = deployment.authority(@map.fetch("authority_id"))
20
+ end
21
+
22
+ def call(operation, params, mutation_id: nil, timeout: nil, upload_parts: nil, download: false, purpose: nil)
23
+ if operation == "register_assignment" && (!upload_parts || purpose != :candidate || download)
24
+ raise ArgumentError, "prepared registration requires fixed candidate upload"
25
+ end
26
+ if operation == "evidence_fetch" && (!download || purpose != (%w[prepared_work campaign_candidate].include?(params["kind"]) ? :candidate : :artifacts) || upload_parts)
27
+ raise ArgumentError, "evidence fetch requires source-fixed kind download"
28
+ end
29
+ if operation == "submit_result" && (!upload_parts || purpose != :receipt_artifacts || download)
30
+ raise ArgumentError, "result submission requires fixed receipt upload"
31
+ end
32
+ if operation == "campaign_export_result" && (!download || purpose != :artifacts || upload_parts || mutation_id)
33
+ raise ArgumentError, "campaign result requires fixed read-only artifact download"
34
+ end
35
+ if operation == "campaign_record_round" && (!upload_parts || purpose != :receipt_artifacts || download)
36
+ raise ArgumentError, "campaign round requires fixed bounded upload"
37
+ end
38
+ @deployment.verify!(mapping_id, kernel: @kernel)
39
+ wire = Ace::Runtime::Molecules::ProtectedSocket
40
+ path = @service.fetch("socket_path")
41
+ before = wire.socket_identity(path)
42
+ raise Ace::Runtime::RuntimeUnavailableError, "authority endpoint owner differs" unless before.last == @service.fetch("uid")
43
+ raise ArgumentError, "cannot upload and download on one request" if upload_parts && download
44
+ prepared_download = operation == "evidence_fetch" && %w[prepared_work campaign_candidate].include?(params["kind"])
45
+ codec = transfer_codec if upload_parts || download && !prepared_download
46
+ descriptor = codec.descriptor(upload_parts, purpose: purpose) if upload_parts
47
+ parameters = upload_parts ? params.merge("transfer" => descriptor) : params
48
+ cap = %w[reserve_attempt close_execution_scope stop_attempt].include?(operation) ? 90 : 30
49
+ duration = timeout || (cap == 90 ? 90 : 5)
50
+ unless duration.is_a?(Numeric) && duration.positive? && duration.finite?
51
+ raise ArgumentError, "authority timeout must be finite and positive"
52
+ end
53
+ deadline = wire.deadline([duration, cap].min)
54
+ wire.connect(path, deadline: deadline) do |socket|
55
+ peer = @kernel.peer(socket)
56
+ unless peer["uid"] == @service.fetch("uid") && peer["gid"] == @service.fetch("gid") &&
57
+ peer["groups"] == @service.fetch("groups") && wire.socket_identity(path) == before
58
+ raise AttemptErrors::UnauthorizedIdentity, "authority endpoint peer changed"
59
+ end
60
+ wire.write(socket, {"version" => 1, "operation" => operation,
61
+ "mutation_id" => mutation_id, "project_id" => @map.fetch("project_id"),
62
+ "params" => parameters.merge("mapping_id" => mapping_id)}, deadline: deadline, limit: upload_parts || download ? 16_384 : wire::LIMIT)
63
+ if %w[bind_inbox finish recover request_review review_status launch_review_intent cancel_review assignment_inventory evidence_fetch observe_execution_scope close_execution_scope stop_attempt prompt_status launch_input_inhibit_selection launch_input_inhibit_completion launch_prompt_intent launch_prompt_completion claim_service_settlement workspace_prune_preview_context].include?(operation) || (operation == "attempt_status" && params.key?("result_candidate_generation"))
64
+ socket.shutdown(Socket::SHUT_WR)
65
+ end
66
+ if upload_parts
67
+ codec.send(socket, parts: upload_parts, descriptor: descriptor, purpose: purpose, deadline: deadline)
68
+ socket.shutdown(Socket::SHUT_WR)
69
+ end
70
+ result = wire.read(socket, deadline: deadline, limit: 16_384)
71
+ unless result.is_a?(Hash) && result["status"] == "ok" && result["data"].is_a?(Hash)
72
+ code = result.is_a?(Hash) ? result.dig("error", "code") : nil
73
+ raise AttemptErrors::BoundedResultUnavailable, "protected authority result exceeds frame bound" if code == "bounded_result"
74
+ raise AttemptErrors::EvidenceUnavailable, "protected authority refused (#{code || 'invalid_response'})"
75
+ end
76
+ transport = result.fetch("transport")
77
+ unless transport.is_a?(Hash) && transport.keys == ["replayed"] && [true, false].include?(transport["replayed"])
78
+ raise AttemptErrors::EvidenceUnavailable, "authority replay metadata is unavailable"
79
+ end
80
+ if operation == "evidence_fetch"
81
+ if %w[prepared_work campaign_candidate].include?(params["kind"]) && transport.fetch("replayed")
82
+ raise AttemptErrors::EvidenceUnavailable, "prepared fetch cannot replay a mutation"
83
+ end
84
+ validate_evidence_download!(result.fetch("data"), params)
85
+ end
86
+ parts = if download
87
+ codec = prepared_transfer_codec(result.fetch("data").fetch("descriptor")) if prepared_download
88
+ codec.receive(socket, descriptor: result.fetch("data").fetch("transfer"), purpose: purpose, deadline: deadline) do |input|
89
+ Array.new(input.count) { |index| input.bytes(index: index) }
90
+ end
91
+ end
92
+ Reply.new(data: result.fetch("data"), replayed: transport.fetch("replayed"), parts: parts)
93
+ end
94
+ rescue SystemCallError, IOError
95
+ raise AttemptErrors::EvidenceUnavailable, "protected authority connection unavailable; no local fallback"
96
+ end
97
+
98
+
99
+ # Fixed original-launcher stream. Public calls retain their existing
100
+ # complete-upload EOF contract; no caller-selected framing flag exists.
101
+ def with_launch_control(state:)
102
+ @deployment.verify!(mapping_id, kernel: @kernel)
103
+ wire = Ace::Runtime::Molecules::ProtectedSocket
104
+ path = @service.fetch("socket_path")
105
+ before = wire.socket_identity(path)
106
+ raise Ace::Runtime::RuntimeUnavailableError, "Authority endpoint owner differs" unless before.last == @service.fetch("uid")
107
+ deadline = wire.deadline(30)
108
+ wire.connect(path, deadline: deadline) do |socket|
109
+ peer = @kernel.peer(socket)
110
+ unless peer.values_at("uid", "gid", "groups") == @service.values_at("uid", "gid", "groups") && wire.socket_identity(path) == before
111
+ raise AttemptErrors::UnauthorizedIdentity, "Authority stream peer changed"
112
+ end
113
+ wire.write(socket, {"version" => 1, "operation" => "launch_control", "mutation_id" => nil,
114
+ "project_id" => @map.fetch("project_id"), "params" => state.slice("assignment_id", "attempt_id").merge("mapping_id" => mapping_id)},
115
+ deadline: deadline, limit: 16_384)
116
+ ready = wire.read(socket, deadline: deadline, limit: 16_384)
117
+ validate_launch_control_ready!(ready, state)
118
+ yield socket, transfer_codec, ready
119
+ end
120
+ rescue IOError, SystemCallError
121
+ raise AttemptErrors::EvidenceUnavailable, "Original launch control connection is unavailable"
122
+ end
123
+
124
+ private
125
+
126
+ def validate_launch_control_ready!(ready, state)
127
+ unless ready.is_a?(Hash) && ready.keys.sort == %w[attempt_id generation journal_commit original_binding_digest type version] &&
128
+ ready["version"].is_a?(Integer) && ready["version"] == 1 && ready["type"] == "launch_control_ready" &&
129
+ ready["attempt_id"] == state.fetch("attempt_id") && ready["generation"].is_a?(Integer) && ready["generation"].positive? &&
130
+ ready["journal_commit"].is_a?(String) && ready["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
131
+ ready["original_binding_digest"].is_a?(String) && ready["original_binding_digest"].match?(/\A[0-9a-f]{64}\z/)
132
+ raise AttemptErrors::EvidenceUnavailable, "Original launch control admission is unavailable"
133
+ end
134
+ true
135
+ end
136
+
137
+ def validate_evidence_download!(data, params)
138
+ descriptor, transfer = data.values_at("descriptor", "transfer")
139
+ if params["kind"] == "prepared_work"
140
+ validate_prepared_download!(data, params)
141
+ return
142
+ end
143
+ if params["kind"] == "campaign_candidate"
144
+ validate_campaign_candidate_download!(data, params)
145
+ return
146
+ end
147
+ unless data.keys.sort == %w[descriptor generation journal_commit transfer] &&
148
+ Molecules::CanonicalEvidence.valid_descriptor?(descriptor) &&
149
+ descriptor["project_id"] == @map.fetch("project_id") &&
150
+ {"assignment_id" => "assignment_id", "attempt_id" => "attempt_id", "kind" => "kind",
151
+ "request_id_or_event_id" => "purpose_id", "artifact_id" => "artifact_id"}.all? { |field, key| descriptor[field] == params[key] } &&
152
+ data["generation"].is_a?(Integer) && data["generation"] >= 0 &&
153
+ data["journal_commit"].is_a?(String) && data["journal_commit"].match?(/\A[0-9a-f]{40,64}\z/) &&
154
+ transfer.is_a?(Hash) && transfer.keys.sort == %w[bytes parts sha256 version] && transfer["version"] == 1 &&
155
+ transfer["bytes"] == descriptor["bytes"] && transfer["sha256"] == descriptor["sha256"] &&
156
+ transfer["parts"] == [descriptor.slice("bytes", "sha256")]
157
+ raise AttemptErrors::EvidenceUnavailable, "canonical download descriptor differs"
158
+ end
159
+ end
160
+
161
+ def transfer_codec
162
+ peer = @kernel.capture(Process.pid)
163
+ project = @deployment.project(@map.fetch("project_id"))
164
+ installed = project.fetch("peer_credentials").fetch(peer.fetch("uid").to_s)
165
+ unless peer["gid"] == installed["gid"] && peer["groups"] == installed["groups"]
166
+ raise AttemptErrors::UnauthorizedIdentity, "transfer scratch principal differs"
167
+ end
168
+ TransferCodec.new(root: installed.fetch("scratch_root"))
169
+ end
170
+
171
+ def validate_prepared_download!(data, params)
172
+ descriptor, transfer = data.values_at("descriptor", "transfer")
173
+ fields = %w[version kind purpose artifact project_id mapping_id assignment_id attempt_id task_id scope definition_digest selection_sha256 prepared_head prepared_tree manifest_bytes manifest_sha256 registration_generation registration_commit original_binding_digest ref bytes sha256 task_context_entry original_worker_identity original_worker_scratch_root workspace_exclusion].sort
174
+ valid = data.keys.sort == %w[descriptor generation journal_commit transfer] && descriptor.is_a?(Hash) && descriptor.keys.sort == fields &&
175
+ descriptor["version"].is_a?(Integer) && descriptor["version"] == 1 &&
176
+ params.values_at("kind", "purpose_id", "artifact_id") == %w[prepared_work original_prepared_work prepared_bundle] &&
177
+ descriptor.values_at("kind", "purpose", "artifact") == %w[prepared_work original_prepared_work prepared_bundle] &&
178
+ descriptor["project_id"] == @map.fetch("project_id") && descriptor["mapping_id"] == mapping_id &&
179
+ %w[assignment_id attempt_id].all? { |key| descriptor[key] == params[key] } &&
180
+ descriptor["task_id"].is_a?(String) && descriptor["task_id"].match?(Deployment::TOKEN) &&
181
+ descriptor["scope"].is_a?(String) && descriptor["scope"].bytesize.between?(1, 128) && descriptor["scope"].match?(/\A[0-9]+(?:\.[0-9]+)*\z/) &&
182
+ %w[definition_digest selection_sha256 manifest_sha256 original_binding_digest sha256].all? { |key| descriptor[key].is_a?(String) && descriptor[key].match?(/\A[0-9a-f]{64}\z/) } &&
183
+ %w[prepared_head prepared_tree].all? { |key| descriptor[key].is_a?(String) && descriptor[key].match?(/\A[0-9a-f]{40}\z/) } &&
184
+ descriptor["manifest_bytes"].is_a?(Integer) && descriptor["manifest_bytes"].between?(1, 32_768) &&
185
+ descriptor["registration_generation"].is_a?(Integer) && descriptor["registration_generation"].positive? &&
186
+ descriptor["bytes"].is_a?(Integer) && descriptor["bytes"].between?(1, TransferCodec::LIMITS.fetch(:candidate).first) &&
187
+ descriptor["registration_commit"].is_a?(String) && descriptor["registration_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
188
+ descriptor["ref"] == "execution/prepared/#{params.fetch('assignment_id')}-#{descriptor['sha256']}.bundle" &&
189
+ data["generation"].is_a?(Integer) && data["generation"].positive? &&
190
+ data["journal_commit"].is_a?(String) && data["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
191
+ transfer.is_a?(Hash) && transfer.keys.sort == %w[bytes parts sha256 version] && transfer["version"].is_a?(Integer) && transfer["version"] == 1 &&
192
+ transfer.values_at("bytes", "sha256") == descriptor.values_at("bytes", "sha256") && transfer["parts"] == [descriptor.slice("bytes", "sha256")]
193
+ raise AttemptErrors::EvidenceUnavailable, "original prepared download descriptor differs" unless valid
194
+ TaskContextEntry.validate!(descriptor.fetch("task_context_entry"))
195
+ Molecules::LifecycleExclusion.workspace_reader(projection: descriptor.fetch("workspace_exclusion"))
196
+ unless descriptor.dig("workspace_exclusion", "root_resource", "view_path") ==
197
+ "/run/ace/lifecycle-exclusion/#{descriptor.fetch('mapping_id')}"
198
+ raise AttemptErrors::EvidenceUnavailable, "original prepared lifecycle mapping differs"
199
+ end
200
+ identity = descriptor.fetch("original_worker_identity")
201
+ birth = identity.is_a?(Hash) && identity["started_at"]
202
+ boot = birth.is_a?(String) && birth.split(":", -1)[1]
203
+ Molecules::ExecutionScopeLineage.validate_process_identity!(identity, boot_id: boot)
204
+ root = descriptor.fetch("original_worker_scratch_root")
205
+ unless root.is_a?(String) && root.valid_encoding? && root.bytesize.between?(2, 4096) &&
206
+ root.start_with?("/") && !root.include?("\0") && File.expand_path(root) == root
207
+ raise AttemptErrors::EvidenceUnavailable, "original prepared scratch root differs"
208
+ end
209
+ rescue ArgumentError, KeyError
210
+ raise AttemptErrors::EvidenceUnavailable, "original prepared download descriptor differs"
211
+ end
212
+
213
+ def validate_campaign_candidate_download!(data, params)
214
+ descriptor, transfer = data.values_at("descriptor", "transfer")
215
+ fields = %w[head tree bytes sha256 candidate_generation campaign_execution mapping_id assignment_id attempt_id
216
+ project_id original_worker_identity original_worker_scratch_root original_binding_digest].sort
217
+ unless data.keys.sort == %w[descriptor generation journal_commit transfer] &&
218
+ descriptor.is_a?(Hash) && descriptor.keys.sort == fields &&
219
+ params.values_at("purpose_id", "artifact_id") == %w[original_campaign_candidate candidate_bundle] &&
220
+ descriptor["mapping_id"] == mapping_id && descriptor["project_id"] == @map.fetch("project_id") &&
221
+ %w[assignment_id attempt_id].all? { |key| descriptor[key] == params[key] } &&
222
+ %w[head tree].all? { |key| descriptor[key].is_a?(String) && descriptor[key].match?(CandidateTransfer::SHA) } &&
223
+ %w[sha256 original_binding_digest].all? { |key| descriptor[key].is_a?(String) && descriptor[key].match?(/\A[0-9a-f]{64}\z/) } &&
224
+ descriptor["candidate_generation"].is_a?(Integer) && descriptor["candidate_generation"].positive? &&
225
+ descriptor["bytes"].is_a?(Integer) && descriptor["bytes"].between?(1, CandidateTransfer::MAX_BYTES) &&
226
+ descriptor["campaign_execution"].is_a?(Hash) && descriptor["campaign_execution"].keys.sort == CampaignExecution::FIELDS &&
227
+ data["generation"].is_a?(Integer) && data["generation"].positive? &&
228
+ data["journal_commit"].is_a?(String) && data["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
229
+ transfer.is_a?(Hash) && transfer.keys.sort == %w[bytes parts sha256 version] && transfer["version"] == 1 &&
230
+ transfer["version"].is_a?(Integer) &&
231
+ transfer.values_at("bytes", "sha256") == descriptor.values_at("bytes", "sha256") && transfer["parts"] == [descriptor.slice("bytes", "sha256")]
232
+ raise AttemptErrors::EvidenceUnavailable, "original campaign candidate download differs"
233
+ end
234
+ identity = descriptor.fetch("original_worker_identity")
235
+ birth = identity.is_a?(Hash) && identity["started_at"]
236
+ Molecules::ExecutionScopeLineage.validate_process_identity!(identity, boot_id: birth.to_s.split(":", -1)[1])
237
+ root = descriptor.fetch("original_worker_scratch_root")
238
+ unless root.is_a?(String) && root.valid_encoding? && root.bytesize.between?(2, 4096) &&
239
+ root.start_with?("/") && !root.include?("\0") && File.expand_path(root) == root
240
+ raise AttemptErrors::EvidenceUnavailable, "original campaign scratch root differs"
241
+ end
242
+ rescue ArgumentError, KeyError
243
+ raise AttemptErrors::EvidenceUnavailable, "original campaign candidate download differs"
244
+ end
245
+
246
+ def prepared_transfer_codec(descriptor)
247
+ peer = @kernel.capture(Process.pid)
248
+ original = descriptor.fetch("original_worker_identity")
249
+ unless peer.values_at("uid", "gid", "groups") == original.values_at("uid", "gid", "groups")
250
+ raise AttemptErrors::UnauthorizedIdentity, "original prepared scratch principal differs"
251
+ end
252
+ TransferCodec.new(root: descriptor.fetch("original_worker_scratch_root"))
253
+ end
254
+ end
255
+ end
256
+ end
257
+ end