ace-runtime 0.1.1 → 0.3.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 (29) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -0
  3. data/README.md +9 -0
  4. data/docs/usage.md +123 -0
  5. data/lib/ace/runtime/molecules/cgroup_observation.rb +148 -0
  6. data/lib/ace/runtime/molecules/codex_runtime_installation.rb +130 -0
  7. data/lib/ace/runtime/molecules/execution_boot_baseline.rb +134 -0
  8. data/lib/ace/runtime/molecules/execution_network_selection.rb +85 -0
  9. data/lib/ace/runtime/molecules/execution_unit_installation.rb +663 -0
  10. data/lib/ace/runtime/molecules/kernel_view_topology.rb +133 -0
  11. data/lib/ace/runtime/molecules/linux_mount_info.rb +101 -0
  12. data/lib/ace/runtime/molecules/network_installation_evidence.rb +332 -0
  13. data/lib/ace/runtime/molecules/process_identity.rb +123 -0
  14. data/lib/ace/runtime/molecules/protected_artifact_set.rb +211 -0
  15. data/lib/ace/runtime/molecules/protected_linux.rb +118 -0
  16. data/lib/ace/runtime/molecules/protected_socket.rb +119 -0
  17. data/lib/ace/runtime/molecules/protected_task_context_entry.rb +162 -0
  18. data/lib/ace/runtime/molecules/protected_worker_entry.rb +32 -0
  19. data/lib/ace/runtime/molecules/readiness_configuration.rb +154 -0
  20. data/lib/ace/runtime/molecules/readiness_runtime.rb +94 -0
  21. data/lib/ace/runtime/molecules/server_resource_observation.rb +183 -0
  22. data/lib/ace/runtime/molecules/systemd_scope_manager.rb +411 -0
  23. data/lib/ace/runtime/testing/adapter_contract.rb +9 -0
  24. data/lib/ace/runtime/version.rb +1 -1
  25. data/lib/ace/runtime.rb +3 -0
  26. data/native/README.md +124 -0
  27. data/native/build-worker-gate +20 -0
  28. data/native/worker_gate.c +385 -0
  29. metadata +63 -2
@@ -0,0 +1,411 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "protected_socket"
4
+ require "json"
5
+
6
+ module Ace
7
+ module Runtime
8
+ module Molecules
9
+ # Fixed installed system-manager units only. This adapter does not decide
10
+ # when a generation may start/stop: canonical lifecycle owns admission.
11
+ class SystemdScopeManager
12
+ SYSTEMCTL = "/usr/bin/systemctl"
13
+ BUSCTL = "/usr/bin/busctl"
14
+ UNIT = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
15
+ PROPERTIES = %w[Id LoadState ActiveState SubState InvocationID ControlGroup Job
16
+ FragmentPath DropInPaths].freeze
17
+ SERVICE_PROPERTIES = (PROPERTIES + %w[MainPID Slice]).freeze
18
+ UNIT_GRAPH_SIGNATURES = {
19
+ "Id" => "s", "Names" => "as", "LoadState" => "s", "FragmentPath" => "s", "DropInPaths" => "as",
20
+ "UnitFileState" => "s", "NeedDaemonReload" => "b", "StopWhenUnneeded" => "b", "DefaultDependencies" => "b",
21
+ **%w[Requires Requisite Wants BindsTo PartOf Upholds RequiredBy RequisiteOf WantedBy BoundBy UpheldBy
22
+ ConsistsOf After Before OnSuccess OnSuccessOf OnFailure OnFailureOf Triggers TriggeredBy
23
+ PropagatesStopTo StopPropagatedFrom JoinsNamespaceOf RequiresMountsFor WantsMountsFor].to_h { |key| [key, "as"] }
24
+ }.freeze
25
+ SERVICE_EXEC_SIGNATURES = {
26
+ **%w[Type User Group Restart KillMode ProtectSystem RootDirectory RootImage NetworkNamespacePath Slice StandardInput TTYPath StandardOutput StandardError WorkingDirectory RuntimeDirectoryPreserve DevicePolicy PAMName].to_h { |key| [key, "s"] },
27
+ **%w[SupplementaryGroups ReadWritePaths ReadOnlyPaths Environment PassEnvironment UnsetEnvironment Sockets InaccessiblePaths ExtensionDirectories RuntimeDirectory].to_h { |key| [key, "as"] },
28
+ **%w[SendSIGKILL Delegate ProtectControlGroups NoNewPrivileges PrivateIPC PrivateDevices DynamicUser RootEphemeral MountAPIVFS PrivateTmp ProtectKernelTunables BindLogSockets].to_h { |key| [key, "b"] },
29
+ **%w[CapabilityBoundingSet AmbientCapabilities RestrictNamespaces].to_h { |key| [key, "t"] },
30
+ "RuntimeDirectoryMode" => "u", "UMask" => "u", "RootImageOptions" => "a(ss)", "TemporaryFileSystem" => "a(ss)", "MountImages" => "a(ssba(ss))",
31
+ "ExtensionImages" => "a(sba(ss))", "EnvironmentFiles" => "a(sb)", "RestartForceExitStatus" => "(aiai)", "RestrictAddressFamilies" => "(bas)",
32
+ "DeviceAllow" => "a(ss)", "BindPaths" => "a(ssbt)", "BindReadOnlyPaths" => "a(ssbt)",
33
+ **%w[ExecConditionEx ExecStartPreEx ExecStartEx ExecStartPostEx ExecReloadEx ExecStopEx ExecStopPostEx].to_h { |key| [key, "a(sasasttttuii)"] }
34
+ }.freeze
35
+
36
+ UNIT_STATE_SIGNATURES = {"Id" => "s", "LoadState" => "s", "ActiveState" => "s", "SubState" => "s", "Job" => "(uo)"}.freeze
37
+ MOUNT_SIGNATURES = {"Where" => "s"}.freeze
38
+ ACTIVATION_UNIT_SIGNATURES = UNIT_STATE_SIGNATURES.merge("InvocationID" => "ay", "ControlGroup" => "s").freeze
39
+ ACTIVATION_SERVICE_SIGNATURES = {"MainPID" => "u", "ControlPID" => "u", "Slice" => "s"}.freeze
40
+ INBOX_CONTEXT_SIGNATURES = {"TimeoutStopUSec" => "t"}.freeze
41
+
42
+ PIDFD_ARGV = [BUSCTL, "--system", "--no-pager", "--json=short", "--auto-start=no",
43
+ "--allow-interactive-authorization=no", "--timeout=5", "call", "org.freedesktop.systemd1",
44
+ "/org/freedesktop/systemd1", "org.freedesktop.systemd1.Manager", "GetUnitByPIDFD", "h", "3"].freeze
45
+
46
+ class Command
47
+ LIMIT = 65_536
48
+
49
+ def call(argv, timeout:, pidfd: nil)
50
+ inherited = inherited_descriptor_options(argv, pidfd)
51
+ unless RUBY_PLATFORM.include?("linux") && File.directory?("/run/systemd/system")
52
+ raise RuntimeUnavailableError, "execution scope requires the Linux system systemd manager"
53
+ end
54
+ client = argv.first
55
+ unless [SYSTEMCTL, BUSCTL].include?(client)
56
+ raise RuntimeUnavailableError, "system manager client is not fixed"
57
+ end
58
+ ProtectedSocket.root_path!(client)
59
+ unless File.executable?(client) && (File.stat(client).mode & 0o6000).zero?
60
+ raise RuntimeUnavailableError, "installed system manager client is unsafe"
61
+ end
62
+ output_reader, output_writer = IO.pipe
63
+ error_reader, error_writer = IO.pipe
64
+ pid = Process.spawn({"PATH" => "/usr/bin:/bin", "LANG" => "C", "LC_ALL" => "C"}, *argv,
65
+ in: File::NULL, out: output_writer, err: error_writer, unsetenv_others: true, close_others: true, **inherited)
66
+ output_writer.close
67
+ error_writer.close
68
+ deadline = ProtectedSocket.deadline(timeout)
69
+ readers = [output_reader, error_reader]
70
+ bytes = {output_reader => +"", error_reader => +""}
71
+ until readers.empty?
72
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
73
+ ready = remaining.positive? && IO.select(readers, nil, nil, remaining)
74
+ raise RuntimeUnavailableError, "system manager job outcome is unavailable" unless ready
75
+ ready.first.each do |reader|
76
+ chunk = reader.read_nonblock(4096, exception: false)
77
+ next if chunk == :wait_readable
78
+ if chunk.nil?
79
+ readers.delete(reader)
80
+ else
81
+ bytes.fetch(reader) << chunk
82
+ if bytes.values.sum(&:bytesize) > LIMIT
83
+ raise RuntimeUnavailableError, "system manager response is oversized"
84
+ end
85
+ end
86
+ end
87
+ end
88
+ loop do
89
+ waited = Process.waitpid(pid, Process::WNOHANG)
90
+ if waited
91
+ status = $?
92
+ pid = nil
93
+ raise RuntimeUnavailableError, "fixed system manager operation was refused" unless status.success?
94
+ return bytes.fetch(output_reader)
95
+ end
96
+ unless Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
97
+ raise RuntimeUnavailableError, "system manager job outcome is unavailable"
98
+ end
99
+ sleep 0.01
100
+ end
101
+ rescue SystemCallError, IOError
102
+ raise RuntimeUnavailableError, "system manager operation is unavailable"
103
+ ensure
104
+ [output_reader, output_writer, error_reader, error_writer].compact.each do |stream|
105
+ stream.close unless stream.closed?
106
+ end
107
+ if pid
108
+ begin
109
+ Process.kill("KILL", pid)
110
+ rescue Errno::ESRCH
111
+ nil
112
+ ensure
113
+ begin
114
+ Process.waitpid(pid)
115
+ rescue Errno::ECHILD
116
+ nil
117
+ end
118
+ end
119
+ end
120
+ end
121
+ private
122
+
123
+ def inherited_descriptor_options(argv, pidfd)
124
+ return {} unless pidfd || argv == PIDFD_ARGV
125
+ unless argv == PIDFD_ARGV && pidfd.is_a?(IO) && !pidfd.closed? && pidfd.fileno >= 3
126
+ raise ArgumentError, "pidfd transport requires the fixed manager method and held IO"
127
+ end
128
+ {3 => pidfd}
129
+ end
130
+ end
131
+
132
+ def initialize(slice_unit:, service_unit:, command: Command.new)
133
+ unless slice_unit.is_a?(String) && service_unit.is_a?(String) &&
134
+ UNIT.match?(slice_unit) && UNIT.match?(service_unit) &&
135
+ slice_unit.end_with?(".slice") && service_unit.end_with?(".service") &&
136
+ !%w[system.slice user.slice machine.slice].include?(slice_unit)
137
+ raise ArgumentError, "execution scope requires fixed dedicated slice/service units"
138
+ end
139
+ @slice_unit, @service_unit, @command = slice_unit, service_unit, command
140
+ @slice_ancestors = []
141
+ parent = slice_unit
142
+ loop do
143
+ stem = parent.delete_suffix(".slice")
144
+ parent = stem.include?("-") ? stem.rpartition("-").first + ".slice" : "-.slice"
145
+ @slice_ancestors << parent
146
+ break if parent == "-.slice"
147
+ end
148
+ end
149
+
150
+ # The root identity owner supplies its already policy-checked pinned
151
+ # lifetime. A wire descriptor number never selects this capability.
152
+ def unit_for_pidfd(handle:, timeout: 5)
153
+ unless handle.is_a?(IO) && !handle.closed?
154
+ raise ArgumentError, "manager identity requires a held process lifetime"
155
+ end
156
+ bytes = @command.call(PIDFD_ARGV, timeout: bounded_timeout(timeout), pidfd: handle)
157
+ value = strict_typed_json(bytes)
158
+ data = value["data"]
159
+ unless value.keys.sort == %w[data type] && value["type"] == "osay" &&
160
+ data.is_a?(Array) && data.size == 3 && data[0] == unit_object(@service_unit) &&
161
+ data[1] == @service_unit && invocation_bytes?(data[2]) && data[2].any?(&:positive?)
162
+ raise RuntimeUnavailableError, "manager lifetime belongs to another unit or invocation"
163
+ end
164
+ {"unit" => @service_unit, "invocation_id" => data[2].pack("C*").unpack1("H*")}.freeze
165
+ rescue JSON::ParserError, KeyError, TypeError
166
+ raise RuntimeUnavailableError, "typed manager lifetime response is malformed"
167
+ end
168
+
169
+ def inspect_units
170
+ slice = show(@slice_unit)
171
+ service = show(@service_unit, properties: SERVICE_PROPERTIES)
172
+ unless slice["LoadState"] == "loaded" && service["LoadState"] == "loaded" &&
173
+ service["Slice"] == @slice_unit
174
+ raise RuntimeUnavailableError, "fixed execution units are missing or their placement changed"
175
+ end
176
+ {"slice" => slice, "service" => service}
177
+ end
178
+
179
+ def start_slice
180
+ operate("start", @slice_unit)
181
+ end
182
+
183
+ def inspect_activation
184
+ inspect_units
185
+ slice = typed_properties(unit: @slice_unit, interface: "Unit", signatures: ACTIVATION_UNIT_SIGNATURES)
186
+ service = typed_properties(unit: @service_unit, interface: "Unit", signatures: ACTIVATION_UNIT_SIGNATURES)
187
+ service.merge!(typed_properties(unit: @service_unit, interface: "Service", signatures: ACTIVATION_SERVICE_SIGNATURES))
188
+ unless slice.fetch("Id") == @slice_unit && service.fetch("Id") == @service_unit &&
189
+ slice.fetch("LoadState") == "loaded" && service.fetch("LoadState") == "loaded" && service.fetch("Slice") == @slice_unit
190
+ raise RuntimeUnavailableError, "fixed activation escaped its parent slice"
191
+ end
192
+ {"slice" => slice, "service" => service}
193
+ end
194
+
195
+ def inspect_profile
196
+ # `show` loads installed metadata before typed reads; it starts no unit.
197
+ inspect_units
198
+ slice = typed_properties(unit: @slice_unit, interface: "Unit", signatures: UNIT_GRAPH_SIGNATURES)
199
+ service = typed_properties(unit: @service_unit, interface: "Unit", signatures: UNIT_GRAPH_SIGNATURES).merge(
200
+ typed_properties(unit: @service_unit, interface: "Service", signatures: SERVICE_EXEC_SIGNATURES))
201
+ ancestors = @slice_ancestors.to_h do |unit|
202
+ show(unit)
203
+ [unit, typed_properties(unit: unit, interface: "Unit", signatures: UNIT_GRAPH_SIGNATURES)]
204
+ end
205
+ {"slice" => slice, "service" => service, "ancestors" => ancestors, "unit_paths" => unit_paths,
206
+ "manager_environment" => manager_environment, "prerequisites" => inspect_prerequisites(service)}
207
+ end
208
+
209
+ def inspect_inbox_context_profile
210
+ profile = inspect_profile
211
+ profile.fetch("service").merge!(typed_properties(unit: @service_unit, interface: "Service",
212
+ signatures: INBOX_CONTEXT_SIGNATURES))
213
+ profile
214
+ end
215
+
216
+ def manager_environment(timeout: 5)
217
+ bytes = @command.call([BUSCTL, "--system", "--no-pager", "--json=short", "--auto-start=no",
218
+ "--allow-interactive-authorization=no", "get-property", "org.freedesktop.systemd1",
219
+ "/org/freedesktop/systemd1", "org.freedesktop.systemd1.Manager", "Environment"], timeout: bounded_timeout(timeout))
220
+ unless bytes.is_a?(String) && bytes.bytesize.between?(1, 65_536) && bytes.lines.size == 1
221
+ raise RuntimeUnavailableError, "effective manager environment is unavailable"
222
+ end
223
+ value = JSON.parse(bytes, create_additions: false, allow_duplicate_key: false, allow_comments: false, max_nesting: 4)
224
+ unless value.is_a?(Hash) && value.keys.sort == %w[data type] && value["type"] == "as" &&
225
+ value["data"].is_a?(Array) && value["data"].size <= 256 &&
226
+ value["data"].all? { |entry| entry.is_a?(String) && entry.bytesize.between?(1, 4096) && !entry.include?("\0") && entry.match?(/\A[A-Za-z_][A-Za-z_0-9]*=.*\z/) } &&
227
+ value["data"].map { |entry| entry.split("=", 2).first }.uniq.size == value["data"].size
228
+ raise RuntimeUnavailableError, "effective manager environment differs"
229
+ end
230
+ value.fetch("data")
231
+ rescue JSON::ParserError, ArgumentError
232
+ raise RuntimeUnavailableError, "effective manager environment is malformed"
233
+ end
234
+
235
+ def unit_paths
236
+ bytes = @command.call([BUSCTL, "--system", "--no-pager", "--json=short", "--auto-start=no",
237
+ "--allow-interactive-authorization=no", "get-property", "org.freedesktop.systemd1",
238
+ "/org/freedesktop/systemd1", "org.freedesktop.systemd1.Manager", "UnitPath"], timeout: 5)
239
+ unless bytes.is_a?(String) && bytes.bytesize.between?(1, Command::LIMIT) && bytes.lines.size == 1
240
+ raise RuntimeUnavailableError, "system manager lookup paths are unavailable"
241
+ end
242
+ value = JSON.parse(bytes)
243
+ unless value.is_a?(Hash) && value.keys.sort == %w[data type] && value["type"] == "as" &&
244
+ value["data"].is_a?(Array) && value["data"].size.between?(1, 64) &&
245
+ value["data"].all? { |path| path.is_a?(String) && path.start_with?("/") && !path.include?("\0") && File.expand_path(path) == path }
246
+ raise RuntimeUnavailableError, "system manager lookup paths differ"
247
+ end
248
+ value.fetch("data")
249
+ rescue JSON::ParserError, KeyError
250
+ raise RuntimeUnavailableError, "system manager lookup paths are malformed"
251
+ end
252
+
253
+ # Typed reads only, against the existing system manager. Property sets
254
+ # and unit identities are owner-selected, never request-controlled.
255
+ def typed_properties(unit:, interface:, signatures:, timeout: 5)
256
+ unless [@slice_unit, @service_unit, *@slice_ancestors, *@prerequisite_units.to_a].include?(unit) &&
257
+ %w[Unit Service Mount].include?(interface) && (interface == "Unit" || interface == "Service" && unit == @service_unit || interface == "Mount" && @prerequisite_units.to_a.include?(unit) && unit.end_with?(".mount")) && signatures.is_a?(Hash) &&
258
+ signatures.all? { |key, value|
259
+ allowed = case interface
260
+ when "Unit" then UNIT_GRAPH_SIGNATURES.merge(ACTIVATION_UNIT_SIGNATURES)
261
+ when "Service" then SERVICE_EXEC_SIGNATURES.merge(ACTIVATION_SERVICE_SIGNATURES).merge(INBOX_CONTEXT_SIGNATURES)
262
+ when "Mount" then MOUNT_SIGNATURES
263
+ end
264
+ allowed[key] == value
265
+ } && !signatures.empty?
266
+ raise ArgumentError, "typed inspection requires fixed unit properties"
267
+ end
268
+ object = unit_object(unit)
269
+ bytes = @command.call([BUSCTL, "--system", "--no-pager", "--json=short", "--auto-start=no",
270
+ "--allow-interactive-authorization=no", "get-property", "org.freedesktop.systemd1", object,
271
+ "org.freedesktop.systemd1.#{interface}", *signatures.keys], timeout: bounded_timeout(timeout))
272
+ unless bytes.is_a?(String) && bytes.bytesize.between?(1, Command::LIMIT)
273
+ raise RuntimeUnavailableError, "typed manager properties are unavailable"
274
+ end
275
+ lines = bytes.lines
276
+ unless lines.size == signatures.size
277
+ raise RuntimeUnavailableError, "typed manager property set is incomplete"
278
+ end
279
+ signatures.to_a.zip(lines).to_h do |(key, signature), line|
280
+ value = strict_typed_json(line)
281
+ unless value.is_a?(Hash) && value.keys.sort == %w[data type] && value["type"] == signature && typed_value?(signature, value["data"])
282
+ raise RuntimeUnavailableError, "typed manager property signature differs"
283
+ end
284
+ [key, key == "InvocationID" ? value.fetch("data").pack("C*").unpack1("H*") : value.fetch("data")]
285
+ end
286
+ rescue JSON::ParserError, KeyError
287
+ raise RuntimeUnavailableError, "typed manager properties are malformed"
288
+ end
289
+
290
+ def start_service
291
+ operate("start", @service_unit, timeout: 45)
292
+ end
293
+
294
+ def stop_slice
295
+ operate("stop", @slice_unit)
296
+ end
297
+
298
+ def stop_service
299
+ operate("stop", @service_unit)
300
+ end
301
+
302
+ private
303
+
304
+ def inspect_prerequisites(service)
305
+ @prerequisite_units = service.values_at("Requires", "Wants", "After").flatten.uniq - [@slice_unit]
306
+ unless @prerequisite_units.all? { |unit| unit == "systemd-journald.socket" ||
307
+ unit.is_a?(String) && unit.bytesize.between?(7, 255) && unit.end_with?(".mount") && !unit.match?(/[\s\/\0]/) }
308
+ raise RuntimeUnavailableError, "execution service has an unverified implicit prerequisite"
309
+ end
310
+ @prerequisite_units.to_h do |unit|
311
+ value = typed_properties(unit: unit, interface: "Unit", signatures: UNIT_STATE_SIGNATURES)
312
+ value.merge!(typed_properties(unit: unit, interface: "Mount", signatures: MOUNT_SIGNATURES)) if unit.end_with?(".mount")
313
+ [unit, value]
314
+ end
315
+ end
316
+
317
+ def bounded_timeout(value)
318
+ unless value.is_a?(Numeric) && value.finite? && value.positive? && value <= 5
319
+ raise ArgumentError, "manager observation deadline exceeds its fixed budget"
320
+ end
321
+ value
322
+ end
323
+
324
+ def unit_object(unit)
325
+ "/org/freedesktop/systemd1/unit/" + unit.bytes.map { |byte|
326
+ ((byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) || (byte >= 48 && byte <= 57)) ? byte.chr : "_%02x" % byte
327
+ }.join
328
+ end
329
+
330
+ def strict_typed_json(bytes)
331
+ unless bytes.is_a?(String) && bytes.bytesize.between?(1, Command::LIMIT) &&
332
+ bytes.dup.force_encoding(Encoding::UTF_8).valid_encoding?
333
+ raise RuntimeUnavailableError, "typed manager response is unavailable"
334
+ end
335
+ value = JSON.parse(bytes, create_additions: false, max_nesting: 32,
336
+ allow_duplicate_key: false, allow_comments: false)
337
+ raise RuntimeUnavailableError, "typed manager response is malformed" unless value.is_a?(Hash)
338
+ value
339
+ end
340
+
341
+ def invocation_bytes?(value)
342
+ value.is_a?(Array) && value.size == 16 && value.all? { |byte| byte.is_a?(Integer) && byte.between?(0, 255) }
343
+ end
344
+
345
+ def typed_value?(signature, value)
346
+ string = ->(item) { item.is_a?(String) && !item.include?("\0") }
347
+ unsigned = ->(item, bits) { item.is_a?(Integer) && item >= 0 && item < (1 << bits) }
348
+ signed = ->(item) { item.is_a?(Integer) && item >= -(1 << 31) && item < (1 << 31) }
349
+ strings = ->(items) { items.is_a?(Array) && items.all? { |item| string.call(item) } }
350
+ case signature
351
+ when "ay" then invocation_bytes?(value)
352
+ when "s" then string.call(value)
353
+ when "(uo)" then value.is_a?(Array) && value.size == 2 && unsigned.call(value.first, 32) && string.call(value.last) && value.last.start_with?("/")
354
+ when "a(ss)"
355
+ value.is_a?(Array) && value.all? { |item| item.is_a?(Array) && item.size == 2 && item.all? { |part| string.call(part) } }
356
+ when "a(ssba(ss))", "a(sba(ss))"
357
+ strings_count = signature == "a(ssba(ss))" ? 2 : 1
358
+ value.is_a?(Array) && value.all? { |item| item.is_a?(Array) && item.size == strings_count + 2 &&
359
+ item.first(strings_count).all? { |part| string.call(part) } && [true, false].include?(item[strings_count]) &&
360
+ typed_value?("a(ss)", item.last) }
361
+ when "b" then value == true || value == false
362
+ when "t" then unsigned.call(value, 64)
363
+ when "u" then unsigned.call(value, 32)
364
+ when "as" then strings.call(value)
365
+ when "(aiai)"
366
+ value.is_a?(Array) && value.size == 2 && value.all? { |items| items.is_a?(Array) && items.all? { |item| signed.call(item) } }
367
+ when "(bas)"
368
+ value.is_a?(Array) && value.size == 2 && [true, false].include?(value.first) && strings.call(value.last)
369
+ when "a(sb)"
370
+ value.is_a?(Array) && value.all? { |item| item.is_a?(Array) && item.size == 2 && string.call(item.first) && [true, false].include?(item.last) }
371
+ when "a(ssbt)"
372
+ value.is_a?(Array) && value.all? { |item| item.is_a?(Array) && item.size == 4 &&
373
+ string.call(item[0]) && string.call(item[1]) && [true, false].include?(item[2]) && unsigned.call(item[3], 64) }
374
+ when "a(sasasttttuii)"
375
+ value.is_a?(Array) && value.all? { |item| item.is_a?(Array) && item.size == 10 &&
376
+ string.call(item[0]) && strings.call(item[1]) && strings.call(item[2]) &&
377
+ item[3..6].all? { |number| unsigned.call(number, 64) } && unsigned.call(item[7], 32) &&
378
+ signed.call(item[8]) && signed.call(item[9]) }
379
+ else false
380
+ end
381
+ end
382
+
383
+ def operate(verb, unit, timeout: 30)
384
+ @command.call([SYSTEMCTL, "--system", "--no-pager", "--no-ask-password", verb, "--", unit], timeout: timeout)
385
+ true
386
+ end
387
+
388
+ def show(unit, properties: PROPERTIES)
389
+ bytes = @command.call([SYSTEMCTL, "--system", "--no-pager", "--no-ask-password", "show",
390
+ "--all", "--property=#{properties.join(',')}", "--", unit], timeout: 5)
391
+ unless bytes.is_a?(String) && bytes.bytesize.between?(1, Command::LIMIT)
392
+ raise RuntimeUnavailableError, "fixed unit properties are unavailable"
393
+ end
394
+ expected = properties
395
+ properties = {}
396
+ bytes.each_line do |line|
397
+ key, value = line.chomp.split("=", 2)
398
+ unless expected.include?(key) && value && !properties.key?(key) && !value.include?("\0")
399
+ raise RuntimeUnavailableError, "fixed unit property response is malformed"
400
+ end
401
+ properties[key] = value
402
+ end
403
+ unless properties.keys.sort == expected.sort && properties["Id"] == unit
404
+ raise RuntimeUnavailableError, "fixed system manager unit identity differs"
405
+ end
406
+ properties
407
+ end
408
+ end
409
+ end
410
+ end
411
+ end
@@ -43,6 +43,15 @@ module Ace
43
43
 
44
44
  # --- identity and context ---
45
45
 
46
+ def test_unknown_process_identity_never_grants_a_binding_or_mutates_runtime
47
+ pane = prepare_target!
48
+ before = fixture.calls.dup
49
+ [nil, -1, "123"].each do |pid|
50
+ assert_nil adapter.process_binding(pane: pane, caller_pid: pid)
51
+ end
52
+ assert_equal before, fixture.calls
53
+ end
54
+
46
55
  def test_context_reports_scripted_environment
47
56
  fixture.set_context(in_runtime: true, session: "main", window: "work", pane: "%1")
48
57
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Runtime
5
- VERSION = "0.1.1"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
data/lib/ace/runtime.rb CHANGED
@@ -11,6 +11,9 @@ require_relative "runtime/atoms/name_sanitizer"
11
11
  require_relative "runtime/atoms/send_contract"
12
12
  require_relative "runtime/registry"
13
13
  require_relative "runtime/molecules/runtime_selector"
14
+ require_relative "runtime/molecules/process_identity"
15
+ require_relative "runtime/molecules/network_installation_evidence"
16
+ require_relative "runtime/molecules/execution_network_selection"
14
17
 
15
18
  module Ace
16
19
  module Runtime
data/native/README.md ADDED
@@ -0,0 +1,124 @@
1
+ # Protected Linux worker bootstrap
2
+
3
+ The `ace-runtime` gem ships `native/worker_gate.c` and `native/build-worker-gate`.
4
+ This is a Linux executable, not a Ruby interpreter wrapper. Build on the target
5
+ architecture from the installed gem with the system compiler, json-c parser and OpenSSL 3 libcrypto.
6
+ Supported initial architectures are Linux x86_64 and aarch64 with little-endian
7
+ ELF64. A bootstrap built on another architecture fails deployment preflight.
8
+
9
+ On Debian bookworm install the distribution's `build-essential`, `pkg-config`
10
+ `libjson-c-dev` and `libssl-dev` build packages. The executable requires the matching system
11
+ `libjson-c5`, OpenSSL 3 `libcrypto.so.3` (`libssl3`), libc and ELF loader at runtime. Use the same distribution/architecture
12
+ for build and deployment; retain package versions, source SHA, compiler version,
13
+ ELF machine, `readelf -d` dependencies and output SHA256 in installer evidence.
14
+ These libraries and their loader search directories must remain root-owned and
15
+ non-worker-writable. The pinned Herdr service starts with a clean root-controlled
16
+ environment, so no worker loader/preload environment reaches bootstrap startup.
17
+
18
+ From the installed gem directory, in a private unprivileged build directory:
19
+
20
+ ```sh
21
+ native/build-worker-gate /absolute/private/build/ace-worker-gate
22
+ sha256sum /absolute/private/build/ace-worker-gate
23
+ readelf -h -d /absolute/private/build/ace-worker-gate
24
+ ```
25
+
26
+ The administrator installs this exact artifact, without setuid/setgid bits:
27
+
28
+ ```sh
29
+ install -o root -g root -m 0755 /absolute/private/build/ace-worker-gate /usr/libexec/ace-worker-gate
30
+ ```
31
+
32
+ All installation ancestors are root-owned and non-worker-writable. The fixed
33
+ root-owned `/etc/ace/assignment-authorities.json` records `bootstrap` and its exact
34
+ `bootstrap_sha256`. ACE verifies digest, ELF architecture, executable permissions,
35
+ ownership and ancestry before reservation. The native layout uses only this
36
+ absolute executable, installed mapping ID and correlation ticket; cwd and environment come from the root-controlled mapping after authority release.
37
+ The mandatory closed `worker_entry` pins interpreter and wrapper references; arbitrary
38
+ worker argv and shebang/PATH execution are not admitted.
39
+ The map grants no UID switching and the binary is never privileged.
40
+
41
+ The root-installed authority, launcher and native server units require:
42
+
43
+ ```ini
44
+ User=<their distinct mapped account>
45
+ NoNewPrivileges=yes
46
+ CapabilityBoundingSet=
47
+ AmbientCapabilities=
48
+ ```
49
+
50
+ Worker children inherit the server's empty bounds and no-new-privileges policy.
51
+ ACE checks all real/effective/saved/filesystem IDs, supplementary groups, every
52
+ capability set and `NoNewPrivs` from kernel status for all mapped participants,
53
+ including the pinned server. CAP_DAC_OVERRIDE/CAP_SETUID are refused, as is an
54
+ exec context that could gain capabilities or setuid privilege. Yama=2 remains
55
+ mandatory; an empty capability set alone does not stop same-UID injection.
56
+
57
+ The bootstrap sets non-dumpable before connecting, requires enforced Yama
58
+ `ptrace_scope=2`, requires empty Inh/Prm/Eff/Bnd/Amb capability sets and NoNewPrivs=1, verifies
59
+ worker IDs/groups and its retained actual kernel parent birth, authenticates authority
60
+ SO_PEERCRED and protected socket ancestry, and waits at most 30 seconds on one
61
+ stream. It never reconnects or executes after pre-release EOF, malformed input
62
+ or timeout. A committed release whose response is lost remains potentially
63
+ executed; public inspection must retain ownership rather than resend release.
64
+
65
+ The gate accepts only the maintained `ace.assign.authorities/v2` mapping. It
66
+ captures its actual parent PID and kernel boot/start-tick birth before reading
67
+ the map, checks that same birth before and after parent credentials, and repeats
68
+ the check before authority admission and immediately before exec. The canonical
69
+ `gate_ready` authority owner associates the actual gate peer and its parent with
70
+ the original recorded child/server; the map supplies no `server_identity`.
71
+ Changed parent birth, reparenting, inaccessible credentials or changed policy
72
+ refuses. The original retained mapping supplies `worker_entry={interpreter:REF,wrapper:REF}`
73
+ in the authenticated release, rather than selecting code from the current map.
74
+ Interpreter references are bounded to 32MiB and wrapper references to 1MiB.
75
+ Both root-protected regular held files are hashed and their metadata rechecked.
76
+ The interpreter must be an executable native ELF. The verified wrapper is copied
77
+ into a memfd sealed against writes/growth/shrinkage, reopened read-only, and the
78
+ writable descriptor dropped. Selected held interpreter FD4 and code FD5 survive
79
+ `fexecve(4, ["/proc/self/fd/4", "-I", "-S", "-B", "/proc/self/fd/5", "authority", "worker"], env)`.
80
+ Inherited stdin/stdout/stderr remain untouched; no provider lifetime timeout is added.
81
+ The retained original source owner inside the wrapper separately validates and
82
+ executes the accepted Ruby snapshot and its package closure.
83
+
84
+ Release is one compact JSON record containing the original ticket/assignment/
85
+ attempt/generation/commit plus `prepared_input`. That closed object pins the
86
+ original registration generation/commit, definition and original binding digest,
87
+ prepared-work reference, original worker entry pair and exact journal bundle reference/length/SHA256. The
88
+ authority verifies the original bundle before durable issue; subsequent prepared
89
+ fetch authenticates the accepted release pin before returning body bytes. The
90
+ gate validates the closed ten-field pin, including the original workspace exclusion,
91
+ digests and unchanged bounds before exec. The exclusion has five closed fields:
92
+ key, authority UID/GID, readonly root resource and original cwd resource. Each
93
+ resource has the existing eight identity fields. The gate checks integer/path/
94
+ filesystem bounds and exact mapping/view/key association; the retained Ruby owner
95
+ verifies the canonical cwd digest and held filesystem identity before acquiring
96
+ the lifetime reader. This metadata does not itself grant filesystem access.
97
+ The complete encoded release is bounded to16KiB before durable issue and at the gate. It
98
+ does not receive inline work or propagate an input FD to providers. The worker
99
+ uses the existing authenticated original prepared fetch after release.
100
+
101
+ An ordinary Docker kernel with Yama=0 can verify compilation and fail-closed
102
+ policy rejection. It cannot demonstrate protected launch acceptance. A real
103
+ isolated Linux deployment enforcing the complete policy is required for that
104
+ positive proof; installing this gem does not change host sysctls or install OS
105
+ service/account policy. Downstream gad.8 provisions those requirements and
106
+ consumes this shipped build mechanism.
107
+
108
+ ## Fixed digest initialization and acceptance limits
109
+
110
+ Before any OpenSSL API call the gate selects `OPENSSL_INIT_NO_LOAD_CONFIG`.
111
+ It creates a private library context, fixes its provider search path to `/dev/null`
112
+ (a non-directory), explicitly loads only `default`, and fetches `SHA256` with
113
+ `provider=default`. Failure refuses execution; no config/engine/module fallback
114
+ is provided. [Initialization semantics](https://docs.openssl.org/3.0/man3/OPENSSL_init_crypto/),
115
+ [provider search path](https://docs.openssl.org/3.0/man3/OSSL_PROVIDER/), and
116
+ [fixed provider property](https://docs.openssl.org/3.0/man7/OSSL_PROVIDER-default/) and
117
+ [libcrypto built-in default provider](https://docs.openssl.org/3.0/man7/crypto/#default-provider)
118
+ are the maintained API basis. The system libcrypto/loader remain in the accepted
119
+ root-controlled OS artifact closure; this does not attest an arbitrary library build.
120
+
121
+ This source checkpoint does not compile or execute native code. Real ELF,
122
+ OpenSSL linkage/provider availability, memfd seals, retained FD execution and
123
+ Python script/stdlib behavior require installed Linux acceptance. The trusted
124
+ provisioned OS/Python premise remains; no separate Python runtime tree is introduced.
@@ -0,0 +1,20 @@
1
+ #!/bin/sh
2
+ # Build the shipped immutable Linux bootstrap. Installation is a separate root operation.
3
+ set -eu
4
+ umask 077
5
+ [ "$#" -eq 1 ] || { echo 'usage: build-worker-gate /absolute/private/output' >&2; exit 2; }
6
+ [ "$(uname -s)" = Linux ] || { echo 'worker gate requires Linux' >&2; exit 2; }
7
+ case "$1" in /*) ;; *) echo 'output must be absolute' >&2; exit 2;; esac
8
+ source_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
9
+ build_dir=$(mktemp -d)
10
+ trap 'rm -rf -- "$build_dir"' EXIT HUP INT TERM
11
+ env -i PATH=/usr/bin:/bin /usr/bin/pkg-config --atleast-version=3.0.0 libcrypto
12
+ env -i PATH=/usr/bin:/bin /usr/bin/pkg-config --atleast-version=0.15 json-c
13
+ cflags=$(env -i PATH=/usr/bin:/bin /usr/bin/pkg-config --cflags json-c libcrypto)
14
+ libs=$(env -i PATH=/usr/bin:/bin /usr/bin/pkg-config --libs json-c libcrypto)
15
+ # pkg-config values come only from the installed system development package.
16
+ # shellcheck disable=SC2086
17
+ env -i PATH=/usr/bin:/bin /usr/bin/cc -std=c11 -O2 -D_FORTIFY_SOURCE=3 \
18
+ -fstack-protector-strong -fPIE -pie -Wl,-z,relro,-z,now \
19
+ -Wall -Wextra -Werror $cflags "$source_dir/worker_gate.c" $libs -o "$build_dir/ace-worker-gate"
20
+ install -m 0755 "$build_dir/ace-worker-gate" "$1"