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.
- checksums.yaml +4 -4
- data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
- data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
- data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
- data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
- data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
- data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
- data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
- data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
- data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
- data/CHANGELOG.md +43 -0
- data/docs/demo/canonical-skill-source.tape.yml +4 -4
- data/docs/protected-authority.md +266 -0
- data/docs/usage.md +91 -2
- data/handbook/workflow-instructions/assign/drive.wf.md +2 -2
- data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
- data/lib/ace/assign/atoms/delivery_parameters.rb +54 -0
- data/lib/ace/assign/authority/candidate_transfer.rb +160 -0
- data/lib/ace/assign/authority/client.rb +107 -0
- data/lib/ace/assign/authority/deployment.rb +465 -0
- data/lib/ace/assign/authority/endcap.rb +342 -0
- data/lib/ace/assign/authority/endcap_inboxes.rb +314 -0
- data/lib/ace/assign/authority/endcap_results.rb +380 -0
- data/lib/ace/assign/authority/endcap_services.rb +395 -0
- data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
- data/lib/ace/assign/authority/execution_scope_observation.rb +476 -0
- data/lib/ace/assign/authority/launch_driver.rb +114 -0
- data/lib/ace/assign/authority/launch_lifecycle.rb +822 -0
- data/lib/ace/assign/authority/launch_scope_abort.rb +88 -0
- data/lib/ace/assign/authority/launch_scope_admission.rb +84 -0
- data/lib/ace/assign/authority/launch_scope_close.rb +115 -0
- data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
- data/lib/ace/assign/authority/launch_scope_release.rb +180 -0
- data/lib/ace/assign/authority/posix_acl.rb +56 -0
- data/lib/ace/assign/authority/private_directory.rb +33 -0
- data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
- data/lib/ace/assign/authority/router.rb +54 -0
- data/lib/ace/assign/authority/server.rb +230 -0
- data/lib/ace/assign/authority/service_evidence.rb +55 -0
- data/lib/ace/assign/authority/transfer_codec.rb +155 -0
- data/lib/ace/assign/cli/commands/authority/launch.rb +45 -0
- data/lib/ace/assign/cli/commands/authority/serve.rb +30 -0
- data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
- data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
- data/lib/ace/assign/cli/commands/create.rb +9 -1
- data/lib/ace/assign/cli/commands/delivery.rb +57 -0
- data/lib/ace/assign/cli/commands/inbox_reconcile.rb +36 -0
- data/lib/ace/assign/cli/commands/resume.rb +26 -0
- data/lib/ace/assign/cli.rb +22 -1
- data/lib/ace/assign/models/evidence_event.rb +3 -1
- data/lib/ace/assign/molecules/attempt_reconciler.rb +31 -12
- data/lib/ace/assign/molecules/canonical_evidence.rb +150 -0
- data/lib/ace/assign/molecules/evidence_journal.rb +218 -63
- data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
- data/lib/ace/assign/molecules/execution_scope_lineage.rb +327 -0
- data/lib/ace/assign/molecules/journal_mutation.rb +202 -0
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +4 -0
- data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
- data/lib/ace/assign/molecules/receipt_verifier.rb +49 -19
- data/lib/ace/assign/organisms/assignment_executor.rb +6 -0
- data/lib/ace/assign/organisms/attempt_coordinator.rb +312 -13
- data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
- data/lib/ace/assign/organisms/task_assignment_creator.rb +8 -3
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +13 -0
- 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
|