ace-assign 0.63.1 → 0.64.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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
  3. data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
  4. data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
  5. data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
  6. data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
  7. data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
  8. data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
  9. data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
  10. data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
  11. data/CHANGELOG.md +43 -0
  12. data/docs/demo/canonical-skill-source.tape.yml +4 -4
  13. data/docs/protected-authority.md +266 -0
  14. data/docs/usage.md +91 -2
  15. data/handbook/workflow-instructions/assign/drive.wf.md +2 -2
  16. data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
  17. data/lib/ace/assign/atoms/delivery_parameters.rb +54 -0
  18. data/lib/ace/assign/authority/candidate_transfer.rb +160 -0
  19. data/lib/ace/assign/authority/client.rb +107 -0
  20. data/lib/ace/assign/authority/deployment.rb +465 -0
  21. data/lib/ace/assign/authority/endcap.rb +342 -0
  22. data/lib/ace/assign/authority/endcap_inboxes.rb +314 -0
  23. data/lib/ace/assign/authority/endcap_results.rb +380 -0
  24. data/lib/ace/assign/authority/endcap_services.rb +395 -0
  25. data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
  26. data/lib/ace/assign/authority/execution_scope_observation.rb +476 -0
  27. data/lib/ace/assign/authority/launch_driver.rb +114 -0
  28. data/lib/ace/assign/authority/launch_lifecycle.rb +822 -0
  29. data/lib/ace/assign/authority/launch_scope_abort.rb +88 -0
  30. data/lib/ace/assign/authority/launch_scope_admission.rb +84 -0
  31. data/lib/ace/assign/authority/launch_scope_close.rb +115 -0
  32. data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
  33. data/lib/ace/assign/authority/launch_scope_release.rb +180 -0
  34. data/lib/ace/assign/authority/posix_acl.rb +56 -0
  35. data/lib/ace/assign/authority/private_directory.rb +33 -0
  36. data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
  37. data/lib/ace/assign/authority/router.rb +54 -0
  38. data/lib/ace/assign/authority/server.rb +230 -0
  39. data/lib/ace/assign/authority/service_evidence.rb +55 -0
  40. data/lib/ace/assign/authority/transfer_codec.rb +155 -0
  41. data/lib/ace/assign/cli/commands/authority/launch.rb +45 -0
  42. data/lib/ace/assign/cli/commands/authority/serve.rb +30 -0
  43. data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
  44. data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
  45. data/lib/ace/assign/cli/commands/create.rb +9 -1
  46. data/lib/ace/assign/cli/commands/delivery.rb +57 -0
  47. data/lib/ace/assign/cli/commands/inbox_reconcile.rb +36 -0
  48. data/lib/ace/assign/cli/commands/resume.rb +26 -0
  49. data/lib/ace/assign/cli.rb +22 -1
  50. data/lib/ace/assign/models/evidence_event.rb +3 -1
  51. data/lib/ace/assign/molecules/attempt_reconciler.rb +31 -12
  52. data/lib/ace/assign/molecules/canonical_evidence.rb +150 -0
  53. data/lib/ace/assign/molecules/evidence_journal.rb +218 -63
  54. data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
  55. data/lib/ace/assign/molecules/execution_scope_lineage.rb +327 -0
  56. data/lib/ace/assign/molecules/journal_mutation.rb +202 -0
  57. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +4 -0
  58. data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
  59. data/lib/ace/assign/molecules/receipt_verifier.rb +49 -19
  60. data/lib/ace/assign/organisms/assignment_executor.rb +6 -0
  61. data/lib/ace/assign/organisms/attempt_coordinator.rb +312 -13
  62. data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
  63. data/lib/ace/assign/organisms/task_assignment_creator.rb +8 -3
  64. data/lib/ace/assign/version.rb +1 -1
  65. data/lib/ace/assign.rb +13 -0
  66. metadata +107 -8
@@ -0,0 +1,465 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require "digest"
4
+ require "etc"
5
+ require "rbconfig"
6
+ require "openssl"
7
+ require_relative "private_directory"
8
+ require_relative "posix_acl"
9
+ require "ace/runtime/molecules/protected_socket"
10
+ require "ace/runtime/molecules/protected_linux"
11
+ require "ace/runtime/molecules/execution_unit_installation"
12
+
13
+ module Ace
14
+ module Assign
15
+ module Authority
16
+ class Deployment
17
+ PATH = "/etc/ace/assignment-authorities.json"
18
+ TOKEN = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
19
+ attr_reader :data
20
+
21
+ def self.load
22
+ wire = Ace::Runtime::Molecules::ProtectedSocket
23
+ wire.root_path!(PATH)
24
+ raise ArgumentError, "deployment map is oversized" if File.size(PATH) > 65_536
25
+ new(JSON.parse(File.read(PATH)))
26
+ end
27
+
28
+ def initialize(data)
29
+ unless data.is_a?(Hash) && data["schema"] == "ace.assign.authorities/v2" &&
30
+ data.keys.sort == %w[authorities launch_mappings projects schema]
31
+ raise ArgumentError, "invalid assignment authority deployment schema"
32
+ end
33
+ %w[authorities launch_mappings projects].each do |section|
34
+ unless data[section].is_a?(Hash) && data[section].keys.all? { |id| TOKEN.match?(id) }
35
+ raise ArgumentError, "invalid #{section} mapping"
36
+ end
37
+ end
38
+ @data = JSON.parse(JSON.generate(data))
39
+ @data["authorities"].each_value do |service|
40
+ strict!(service, %w[uid gid groups socket_path state_root composition])
41
+ unless %w[launch services].include?(service["composition"])
42
+ raise ArgumentError, "unknown installed authority composition"
43
+ end
44
+ principal!(service, "uid", "gid", "groups")
45
+ %w[socket_path state_root].each { |key| path!(service.fetch(key)) }
46
+ end
47
+ @data["projects"].each_value do |project|
48
+ raise ArgumentError, "invalid project mapping" unless project.is_a?(Hash)
49
+ keys = %w[journal_repository evidence_git_ref evidence_checkout_root assignment_root candidate_root
50
+ launcher_uids reviewer_uids worker_uids service_executor_uids supervisor_uids peer_credentials]
51
+ keys << "service_receivers" if project.key?("service_receivers")
52
+ keys << "inbox_contexts" if project.key?("inbox_contexts")
53
+ strict!(project, keys)
54
+ %w[journal_repository evidence_checkout_root assignment_root candidate_root].each { |key| path!(project.fetch(key)) }
55
+ unless project["evidence_git_ref"] == "refs/ace/execution"
56
+ raise ArgumentError, "protected authority uses the canonical execution ref"
57
+ end
58
+ role_uids = %w[launcher_uids reviewer_uids worker_uids service_executor_uids supervisor_uids].flat_map do |key|
59
+ values = project.fetch(key)
60
+ unless values.is_a?(Array) && values.all? { |id| id.is_a?(Integer) && id.positive? } && values == values.sort.uniq
61
+ raise ArgumentError, "invalid project principal allowlist"
62
+ end
63
+ values
64
+ end.uniq.sort
65
+ peers = project.fetch("peer_credentials")
66
+ unless peers.is_a?(Hash) && peers.keys.sort == role_uids.map(&:to_s).sort
67
+ raise ArgumentError, "fixed project peer credentials are incomplete"
68
+ end
69
+ peers.each do |uid, peer|
70
+ strict!(peer, %w[gid groups scratch_root])
71
+ principal!(peer.merge("uid" => Integer(uid, 10)), "uid", "gid", "groups")
72
+ path!(peer.fetch("scratch_root"))
73
+ end
74
+ receivers = project.fetch("service_receivers", {})
75
+ unless receivers.is_a?(Hash) && receivers.keys.all? { |id| id.is_a?(String) && TOKEN.match?(id) }
76
+ raise ArgumentError, "invalid installed service receiver mapping"
77
+ end
78
+ receivers.each_value do |receiver|
79
+ strict!(receiver, %w[executor_uid socket_path staging_root])
80
+ uid = receiver.fetch("executor_uid")
81
+ conflicting = %w[launcher_uids reviewer_uids worker_uids supervisor_uids].flat_map { |key| project.fetch(key) }
82
+ unless uid.is_a?(Integer) && uid.positive? && project.fetch("service_executor_uids").include?(uid) && !conflicting.include?(uid)
83
+ raise ArgumentError, "receiver requires an unambiguous mapped executor"
84
+ end
85
+ %w[socket_path staging_root].each { |key| path!(receiver.fetch(key)) }
86
+ end
87
+ end
88
+ @data["launch_mappings"].each { |id, mapping| validate_mapping!(id, mapping) }
89
+ validate_execution_slots!
90
+ validate_inbox_contexts!
91
+ endpoints = @data.fetch("authorities").values.map { |service| service.fetch("socket_path") }
92
+ receivers = @data.fetch("projects").values.flat_map { |project| project.fetch("service_receivers", {}).values }
93
+ protected_uids = @data.fetch("authorities").values.map { |service| service.fetch("uid") } +
94
+ @data.fetch("projects").values.flat_map { |project| %w[launcher_uids reviewer_uids worker_uids supervisor_uids].flat_map { |key| project.fetch(key) } }
95
+ if receivers.any? { |receiver| protected_uids.include?(receiver.fetch("executor_uid")) }
96
+ raise ArgumentError, "receiver executor overlaps another installed role"
97
+ end
98
+ endpoints.concat(receivers.map { |receiver| receiver.fetch("socket_path") })
99
+ raise ArgumentError, "authority endpoint has duplicate owners" unless endpoints.uniq.size == endpoints.size
100
+ staging = receivers.map { |receiver| receiver.fetch("staging_root") }
101
+ unless staging.combination(2).none? { |left, right| paths_overlap?(left, right) }
102
+ raise ArgumentError, "receiver staging roots overlap"
103
+ end
104
+ if receivers.any? { |receiver| staging.any? { |root| paths_overlap?(receiver.fetch("socket_path"), root) } }
105
+ raise ArgumentError, "receiver endpoint must be separate from private staging"
106
+ end
107
+ protected_roots = @data.fetch("authorities").values.map { |service| service.fetch("state_root") } +
108
+ @data.fetch("projects").values.flat_map { |project| %w[journal_repository evidence_checkout_root assignment_root candidate_root].map { |key| project.fetch(key) } }
109
+ if staging.any? { |root| protected_roots.any? { |protected| paths_overlap?(root, protected) } }
110
+ raise ArgumentError, "receiver staging overlaps authority state"
111
+ end
112
+ repositories = @data.fetch("projects").values.map { |project| project.fetch("journal_repository") }
113
+ raise ArgumentError, "canonical project repository has duplicate mappings" unless repositories.uniq.size == repositories.size
114
+ @data.fetch("projects").each_key do |project_id|
115
+ owners = @data.fetch("launch_mappings").values.select { |map| map["project_id"] == project_id }.map { |map| map.fetch("authority_id") }.uniq
116
+ raise ArgumentError, "project must have exactly one authority owner" unless owners.size == 1
117
+ service = authority(owners.first)
118
+ installed = project(project_id).fetch("service_receivers", {})
119
+ if service.fetch("composition") == "services" && installed.empty?
120
+ raise ArgumentError, "services composition requires installed receivers"
121
+ end
122
+ end
123
+ end
124
+
125
+ # Static selection only. Live native identity comes from the attempt's
126
+ # canonical scope stage, never from this deployment record.
127
+ def inbox_context(mapping_id, context_id)
128
+ map = mapping(mapping_id)
129
+ contexts = project(map.fetch("project_id")).fetch("inbox_contexts", {})
130
+ context = contexts.fetch(context_id) { raise AttemptErrors::EvidenceUnavailable, "installed inbox context is unavailable" }
131
+ native = mapping(context.fetch("native_mapping_id"))
132
+ unless native.fetch("project_id") == map.fetch("project_id") && native.fetch("authority_id") == map.fetch("authority_id")
133
+ raise AttemptErrors::UnauthorizedIdentity, "inbox context belongs to another authority"
134
+ end
135
+ JSON.parse(JSON.generate(context))
136
+ end
137
+
138
+ def verify_inbox_context!(mapping_id, context_id)
139
+ context = inbox_context(mapping_id, context_id)
140
+ service = authority(mapping(mapping_id).fetch("authority_id"))
141
+ unless Process.uid == service.fetch("uid")
142
+ raise Ace::Runtime::RuntimeUnavailableError, "inbox resolver must run as its installed authority"
143
+ end
144
+ PrivateDirectory.verify!(context.fetch("deliveries_dir"))
145
+ verify_inbox_acl!(context.fetch("deliveries_dir"), private_leaf: true)
146
+ key_path = context.fetch("receipt_public_key")
147
+ client_path = context.fetch("pi_queue_client")
148
+ [key_path, client_path].each do |path|
149
+ Ace::Runtime::Molecules::ProtectedSocket.root_path!(path)
150
+ verify_inbox_acl!(path)
151
+ unless File.file?(path) && !File.symlink?(path) && File.readable?(path)
152
+ raise Ace::Runtime::RuntimeUnavailableError, "installed inbox artifact is unavailable"
153
+ end
154
+ end
155
+ unless File.executable?(client_path) && Digest::SHA256.file(client_path).hexdigest == context.fetch("pi_queue_client_sha256")
156
+ raise Ace::Runtime::RuntimeUnavailableError, "installed inbox identity client differs"
157
+ end
158
+ raise Ace::Runtime::RuntimeUnavailableError, "installed inbox public key is oversized" if File.size(key_path) > 16_384
159
+ key = OpenSSL::PKey.read(File.binread(key_path))
160
+ unless key.is_a?(OpenSSL::PKey::RSA) && !key.private?
161
+ raise Ace::Runtime::RuntimeUnavailableError, "installed inbox key must be public-only RSA"
162
+ end
163
+ context
164
+ rescue AttemptErrors::ReceiptRejected, OpenSSL::PKey::PKeyError, SystemCallError
165
+ raise Ace::Runtime::RuntimeUnavailableError, "installed inbox context evidence is unavailable"
166
+ end
167
+
168
+ def verify_inbox_acl!(path, private_leaf: false)
169
+ current = path
170
+ loop do
171
+ rows = receiver_acl.entries(current)
172
+ if rows
173
+ mask = rows.find { |tag, _perm, _id| tag == 16 }&.at(1) || 7
174
+ allowed = current == path && !private_leaf ? [0] : [0, Process.uid]
175
+ unsafe = rows.any? do |tag, perm, uid|
176
+ effective = [2, 4, 8].include?(tag) ? perm & mask : perm
177
+ case tag
178
+ when 2 then !allowed.include?(uid) && (effective & 2).positive?
179
+ when 4, 8, 32 then (effective & 2).positive?
180
+ else false
181
+ end
182
+ end
183
+ if private_leaf && current == path
184
+ unsafe ||= rows.any? do |tag, perm, uid|
185
+ effective = [2, 4, 8].include?(tag) ? perm & mask : perm
186
+ [4, 8, 32].include?(tag) && effective.positive? || tag == 2 && uid != Process.uid && effective.positive?
187
+ end
188
+ end
189
+ raise Ace::Runtime::RuntimeUnavailableError, "installed inbox access ACL is unsafe" if unsafe
190
+ end
191
+ break if current == "/"
192
+ current = File.dirname(current)
193
+ end
194
+ end
195
+ private :verify_inbox_acl!
196
+
197
+ def validate_inbox_contexts!
198
+ roots = []
199
+ data.fetch("projects").each do |project_id, fixed_project|
200
+ contexts = fixed_project.fetch("inbox_contexts", {})
201
+ unless contexts.is_a?(Hash) && contexts.keys.all? { |id| id.is_a?(String) && TOKEN.match?(id) }
202
+ raise ArgumentError, "invalid installed inbox context map"
203
+ end
204
+ contexts.each_value do |context|
205
+ strict!(context, %w[deliveries_dir receipt_public_key native_mapping_id pi_queue_client pi_queue_client_sha256 supervisor_uids])
206
+ %w[deliveries_dir receipt_public_key pi_queue_client].each { |key| path!(context.fetch(key)) }
207
+ unless context["pi_queue_client_sha256"].is_a?(String) && context["pi_queue_client_sha256"].match?(/\A[0-9a-f]{64}\z/)
208
+ raise ArgumentError, "invalid installed Pi identity client digest"
209
+ end
210
+ supervisors = context.fetch("supervisor_uids")
211
+ unless supervisors.is_a?(Array) && supervisors.all? { |uid| uid.is_a?(Integer) && uid.positive? } &&
212
+ supervisors == supervisors.sort.uniq && (supervisors - fixed_project.fetch("supervisor_uids")).empty?
213
+ raise ArgumentError, "inbox supervisors exceed installed project authority"
214
+ end
215
+ id = context.fetch("native_mapping_id")
216
+ unless id.is_a?(String) && TOKEN.match?(id) && data.fetch("launch_mappings").key?(id)
217
+ raise ArgumentError, "inbox native mapping is unavailable"
218
+ end
219
+ native = mapping(id)
220
+ unless native.fetch("project_id") == project_id && authority(native.fetch("authority_id")).fetch("composition") == "services"
221
+ raise ArgumentError, "inbox context requires its services project mapping"
222
+ end
223
+ roots << context.fetch("deliveries_dir")
224
+ end
225
+ end
226
+ forbidden = data.fetch("projects").values.flat_map do |fixed_project|
227
+ fixed_project.fetch("peer_credentials").values.map { |peer| peer.fetch("scratch_root") } +
228
+ fixed_project.fetch("service_receivers", {}).values.map { |receiver| receiver.fetch("staging_root") } +
229
+ %w[journal_repository evidence_checkout_root assignment_root candidate_root].map { |key| fixed_project.fetch(key) }
230
+ end
231
+ forbidden.concat(data.fetch("authorities").values.map { |service| service.fetch("state_root") })
232
+ if roots.combination(2).any? { |left, right| paths_overlap?(left, right) } ||
233
+ roots.any? { |root| forbidden.any? { |other| paths_overlap?(root, other) } }
234
+ raise ArgumentError, "installed inbox private roots overlap protected state"
235
+ end
236
+ end
237
+ private :validate_inbox_contexts!
238
+
239
+ # Validate fixed receiver placement before the owner opens a listener or
240
+ # creates journal state. Operations/grants remain the Lab policy's job.
241
+ def verify_receiver_paths!(authority_id)
242
+ projects = data.fetch("launch_mappings").values.select { |map| map["authority_id"] == authority_id }.map { |map| map.fetch("project_id") }.uniq
243
+ projects.each do |id|
244
+ project(id).fetch("service_receivers", {}).each_value do |receiver|
245
+ uid = receiver.fetch("executor_uid")
246
+ root = receiver.fetch("staging_root")
247
+ credentials = project(id).fetch("peer_credentials").fetch(uid.to_s)
248
+ groups = (credentials.fetch("groups") + [credentials.fetch("gid")]).uniq
249
+ receiver_directory!(root, uid: uid, groups: groups)
250
+ unless File.executable?(File.dirname(root))
251
+ raise Ace::Runtime::RuntimeUnavailableError, "authority cannot inspect receiver staging"
252
+ end
253
+ stat = File.lstat(root)
254
+ unless stat.uid == uid && (stat.mode & 0o7777) == 0o700
255
+ raise Ace::Runtime::RuntimeUnavailableError, "receiver staging must be executor-private"
256
+ end
257
+ path = receiver.fetch("socket_path")
258
+ receiver_directory!(File.dirname(path), uid: uid, groups: groups)
259
+ unless File.executable?(File.dirname(path))
260
+ raise Ace::Runtime::RuntimeUnavailableError, "authority cannot inspect receiver endpoint"
261
+ end
262
+ if File.exist?(path) || File.symlink?(path)
263
+ endpoint = File.lstat(path)
264
+ unless endpoint.socket? && !endpoint.symlink? && endpoint.uid == uid &&
265
+ endpoint.gid == project(id).fetch("peer_credentials").fetch(uid.to_s).fetch("gid") && (endpoint.mode & 0o007).zero?
266
+ raise Ace::Runtime::RuntimeUnavailableError, "installed receiver endpoint is unsafe"
267
+ end
268
+ end
269
+ end
270
+ end
271
+ true
272
+ rescue SystemCallError
273
+ raise Ace::Runtime::RuntimeUnavailableError, "installed receiver paths are unavailable"
274
+ end
275
+
276
+ def receiver_directory!(path, uid:, groups:)
277
+ current = path
278
+ loop do
279
+ stat = File.lstat(current)
280
+ unless stat.directory? && !stat.symlink? && [0, uid].include?(stat.uid) && (stat.mode & 0o022).zero?
281
+ raise Ace::Runtime::RuntimeUnavailableError, "installed receiver ancestry is unsafe"
282
+ end
283
+ unless receiver_acl.searchable?(current, stat: stat, uid: uid, groups: groups)
284
+ raise Ace::Runtime::RuntimeUnavailableError, "executor cannot traverse receiver ancestry"
285
+ end
286
+ break if current == "/"
287
+ current = File.dirname(current)
288
+ end
289
+ end
290
+ private :receiver_directory!
291
+
292
+ def receiver_acl
293
+ @receiver_acl ||= PosixAcl.new
294
+ end
295
+ private :receiver_acl
296
+
297
+ def paths_overlap?(left, right)
298
+ left == right || left.start_with?(right.chomp("/") + "/") || right.start_with?(left.chomp("/") + "/")
299
+ end
300
+ private :paths_overlap?
301
+
302
+ def verify_composition!(authority_id, composition:)
303
+ unless %w[launch services].include?(composition) && authority(authority_id).fetch("composition") == composition
304
+ raise ArgumentError, "installed authority composition differs from source entrypoint"
305
+ end
306
+ true
307
+ end
308
+
309
+ def authority(id)
310
+ data.fetch("authorities").fetch(id)
311
+ end
312
+
313
+ def project(id)
314
+ data.fetch("projects").fetch(id)
315
+ end
316
+
317
+ def mapping(id)
318
+ data.fetch("launch_mappings").fetch(id)
319
+ end
320
+
321
+ def validate_mapping!(id, mapping)
322
+ required = %w[project_id authority_id launcher_uid launcher_gid launcher_groups worker_uid worker_gid
323
+ worker_groups worker_actor worker_cwd worker_argv worker_env bootstrap bootstrap_sha256 native execution_scope]
324
+ unless mapping.is_a?(Hash) && mapping.keys.sort == required.sort
325
+ raise ArgumentError, "launch mapping fields differ: #{id}"
326
+ end
327
+ service = authority(mapping.fetch("authority_id"))
328
+ fixed_project = project(mapping.fetch("project_id"))
329
+ %w[launcher worker].each do |role|
330
+ uid = mapping.fetch("#{role}_uid")
331
+ expected = fixed_project.fetch("peer_credentials").fetch(uid.to_s)
332
+ unless fixed_project.fetch("#{role}_uids").include?(uid) && expected["gid"] == mapping["#{role}_gid"] && expected["groups"] == mapping["#{role}_groups"]
333
+ raise ArgumentError, "launch credentials differ from project allowlist"
334
+ end
335
+ end
336
+ uids = [service.fetch("uid"), mapping.fetch("launcher_uid"), mapping.fetch("worker_uid")]
337
+ unless uids.uniq.size == 3 && uids.all? { |uid| uid.is_a?(Integer) && uid.positive? }
338
+ raise ArgumentError, "protected launch requires distinct non-root principals"
339
+ end
340
+ %w[launcher worker].each do |role|
341
+ principal!(mapping, "#{role}_uid", "#{role}_gid", "#{role}_groups")
342
+ end
343
+ %w[worker_cwd bootstrap].each { |key| path!(mapping.fetch(key)) }
344
+ native = mapping.fetch("native")
345
+ strict!(native, %w[socket_path executable executable_sha256 version protocol workspace_id])
346
+ %w[socket_path executable].each { |key| path!(native.fetch(key)) }
347
+ unless native["workspace_id"].is_a?(String) && native["workspace_id"].match?(/\Aw[1-9][0-9]{0,8}\z/) &&
348
+ native["version"] == "0.9.3" && native["protocol"] == 22 && digest?(native["executable_sha256"])
349
+ raise ArgumentError, "invalid fixed native artifact or protocol"
350
+ end
351
+ scope = mapping.fetch("execution_scope")
352
+ strict!(scope, %w[backend slot_id slice_unit service_unit unit_manifest_sha256 boundary_manifest_sha256
353
+ root_directory runtime_directory network_namespace_path])
354
+ unless scope["backend"] == "linux_systemd_cgroup_v2" && scope["slot_id"].is_a?(String) && TOKEN.match?(scope["slot_id"]) &&
355
+ %w[unit_manifest_sha256 boundary_manifest_sha256].all? { |key| digest?(scope[key]) }
356
+ raise ArgumentError, "invalid execution scope backend or manifest identity"
357
+ end
358
+ {"slice_unit" => ".slice", "service_unit" => ".service"}.each do |key, suffix|
359
+ name = scope.fetch(key)
360
+ unless name.is_a?(String) && TOKEN.match?(name) && name.end_with?(suffix) &&
361
+ name != "-.slice" && !name.downcase.include?("overseer")
362
+ raise ArgumentError, "invalid fixed protected unit"
363
+ end
364
+ end
365
+ %w[root_directory runtime_directory network_namespace_path].each { |key| path!(scope.fetch(key)) }
366
+ unless scope.fetch("runtime_directory").start_with?("/run/") &&
367
+ !paths_overlap?(scope.fetch("root_directory"), scope.fetch("runtime_directory"))
368
+ raise ArgumentError, "execution scope backing roots overlap or runtime root is invalid"
369
+ end
370
+ argv = mapping.fetch("worker_argv")
371
+ env = mapping.fetch("worker_env")
372
+ unless argv.is_a?(Array) && !argv.empty? && argv.size <= 64 && argv.all? { |v| v.is_a?(String) && !v.include?("\0") } &&
373
+ argv.first.start_with?("/") && env.is_a?(Hash) && env.all? { |k, v| k.match?(/\A[A-Z_][A-Z0-9_]*\z/) && v.is_a?(String) && !v.include?("\0") } &&
374
+ env.keys.none? { |key| key.match?(/\A(?:LD_|DYLD_|RUBY|BUNDLE|PYTHON)/) }
375
+ raise ArgumentError, "invalid fixed worker argv or environment"
376
+ end
377
+ unless mapping["worker_actor"].is_a?(String) && !mapping["worker_actor"].empty? &&
378
+ mapping["bootstrap_sha256"].match?(/\A[0-9a-f]{64}\z/)
379
+ raise ArgumentError, "invalid installed bootstrap or worker actor"
380
+ end
381
+ rescue KeyError, NoMethodError
382
+ raise ArgumentError, "launch mapping is incomplete"
383
+ end
384
+
385
+ def digest?(value)
386
+ value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/)
387
+ end
388
+
389
+ def validate_execution_slots!
390
+ mappings = @data.fetch("launch_mappings").values
391
+ %w[slot_id slice_unit service_unit].each do |key|
392
+ values = mappings.map { |map| map.fetch("execution_scope").fetch(key) }
393
+ raise ArgumentError, "execution slot has duplicate ownership" unless values.uniq.size == values.size
394
+ end
395
+ workers = mappings.map { |map| map.fetch("worker_uid") }
396
+ raise ArgumentError, "protected execution slots share a worker principal" unless workers.uniq.size == workers.size
397
+ roots = mappings.map { |map| map.fetch("execution_scope").values_at("root_directory", "runtime_directory") }
398
+ roots.combination(2).each do |left, right|
399
+ if left.product(right).any? { |a, b| paths_overlap?(a, b) }
400
+ raise ArgumentError, "protected execution slot roots overlap"
401
+ end
402
+ end
403
+ end
404
+
405
+ def strict!(value, keys)
406
+ raise ArgumentError, "deployment fields differ" unless value.is_a?(Hash) && value.keys.sort == keys.sort
407
+ end
408
+
409
+ def path!(value)
410
+ unless value.is_a?(String) && value.start_with?("/") && !value.include?("\0") && File.expand_path(value) == value
411
+ raise ArgumentError, "deployment path must be fixed and absolute"
412
+ end
413
+ end
414
+
415
+ def principal!(value, uid_key, gid_key, groups_key)
416
+ uid, gid, groups = value.values_at(uid_key, gid_key, groups_key)
417
+ unless [uid, gid].all? { |id| id.is_a?(Integer) && id.positive? } && groups.is_a?(Array) &&
418
+ groups.all? { |id| id.is_a?(Integer) && id.positive? } && groups == groups.sort.uniq
419
+ raise ArgumentError, "invalid principal credentials"
420
+ end
421
+ end
422
+
423
+ def elf_architecture?(path)
424
+ header = File.binread(path, 20)
425
+ expected = {"x86_64" => 62, "aarch64" => 183, "arm64" => 183}[RbConfig::CONFIG.fetch("host_cpu")]
426
+ expected && header.bytesize == 20 && header.byteslice(0, 4) == "\x7fELF".b &&
427
+ header.getbyte(4) == 2 && header.getbyte(5) == 1 && header.getbyte(6) == 1 &&
428
+ header.byteslice(18, 2).unpack1("v") == expected
429
+ end
430
+
431
+ def verify!(id, kernel: Ace::Runtime::Molecules::ProtectedLinux.new, authority_state: false, manager: nil)
432
+ kernel.supported!
433
+ map = mapping(id)
434
+ service = authority(map.fetch("authority_id"))
435
+ [service.fetch("uid"), map.fetch("launcher_uid"), map.fetch("worker_uid")].each { |uid| Etc.getpwuid(uid) }
436
+ wire = Ace::Runtime::Molecules::ProtectedSocket
437
+ scope = map.fetch("execution_scope")
438
+ manager ||= Ace::Runtime::Molecules::SystemdScopeManager.new(
439
+ slice_unit: scope.fetch("slice_unit"), service_unit: scope.fetch("service_unit"))
440
+ manifest = Ace::Runtime::Molecules::ExecutionUnitInstallation.new(scope: scope, native: map.fetch("native"),
441
+ bootstrap: map.fetch("bootstrap"), worker_executable: map.fetch("worker_argv").first,
442
+ worker_uid: map.fetch("worker_uid"), worker_gid: map.fetch("worker_gid")).verify!(manager: manager)
443
+ artifacts = manifest.fetch("artifacts")
444
+ bootstrap = artifacts.find { |artifact| artifact.fetch("role") == "bootstrap" }
445
+ executables = artifacts.select { |artifact| %w[bootstrap worker_executable].include?(artifact.fetch("role")) }
446
+ unless bootstrap.fetch("sha256") == map.fetch("bootstrap_sha256") && elf_architecture?(bootstrap.fetch("host_path")) &&
447
+ executables.all? { |artifact| File.executable?(artifact.fetch("host_path")) }
448
+ raise Ace::Runtime::RuntimeUnavailableError, "installed bootstrap or executable is unsafe"
449
+ end
450
+ wire.root_path!(File.dirname(service.fetch("socket_path")), directory: true, owner: service.fetch("uid"))
451
+ if authority_state
452
+ fixed = project(map.fetch("project_id"))
453
+ ([service.fetch("state_root")] + %w[journal_repository evidence_checkout_root assignment_root candidate_root].map { |key| fixed.fetch(key) }).each do |path|
454
+ wire.root_path!(path, directory: true, owner: service.fetch("uid"))
455
+ unless (File.stat(path).mode & 0o077).zero?
456
+ raise Ace::Runtime::RuntimeUnavailableError, "authority state root must remain private"
457
+ end
458
+ end
459
+ end
460
+ map
461
+ end
462
+ end
463
+ end
464
+ end
465
+ end