ace-runtime 0.1.1 → 0.2.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.
@@ -0,0 +1,323 @@
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 StandardOutput StandardError WorkingDirectory RuntimeDirectoryPreserve].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].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
+ "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" => "s", "ControlGroup" => "s").freeze
39
+ ACTIVATION_SERVICE_SIGNATURES = {"MainPID" => "u", "ControlPID" => "u", "Slice" => "s"}.freeze
40
+
41
+ class Command
42
+ LIMIT = 65_536
43
+
44
+ def call(argv, timeout:)
45
+ unless RUBY_PLATFORM.include?("linux") && File.directory?("/run/systemd/system")
46
+ raise RuntimeUnavailableError, "execution scope requires the Linux system systemd manager"
47
+ end
48
+ client = argv.first
49
+ unless [SYSTEMCTL, BUSCTL].include?(client)
50
+ raise RuntimeUnavailableError, "system manager client is not fixed"
51
+ end
52
+ ProtectedSocket.root_path!(client)
53
+ unless File.executable?(client) && (File.stat(client).mode & 0o6000).zero?
54
+ raise RuntimeUnavailableError, "installed system manager client is unsafe"
55
+ end
56
+ output_reader, output_writer = IO.pipe
57
+ error_reader, error_writer = IO.pipe
58
+ pid = Process.spawn({"PATH" => "/usr/bin:/bin", "LANG" => "C", "LC_ALL" => "C"}, *argv,
59
+ in: File::NULL, out: output_writer, err: error_writer, unsetenv_others: true, close_others: true)
60
+ output_writer.close
61
+ error_writer.close
62
+ deadline = ProtectedSocket.deadline(timeout)
63
+ readers = [output_reader, error_reader]
64
+ bytes = {output_reader => +"", error_reader => +""}
65
+ until readers.empty?
66
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
67
+ ready = remaining.positive? && IO.select(readers, nil, nil, remaining)
68
+ raise RuntimeUnavailableError, "system manager job outcome is unavailable" unless ready
69
+ ready.first.each do |reader|
70
+ chunk = reader.read_nonblock(4096, exception: false)
71
+ next if chunk == :wait_readable
72
+ if chunk.nil?
73
+ readers.delete(reader)
74
+ else
75
+ bytes.fetch(reader) << chunk
76
+ if bytes.values.sum(&:bytesize) > LIMIT
77
+ raise RuntimeUnavailableError, "system manager response is oversized"
78
+ end
79
+ end
80
+ end
81
+ end
82
+ loop do
83
+ waited = Process.waitpid(pid, Process::WNOHANG)
84
+ if waited
85
+ status = $?
86
+ pid = nil
87
+ raise RuntimeUnavailableError, "fixed system manager operation was refused" unless status.success?
88
+ return bytes.fetch(output_reader)
89
+ end
90
+ unless Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
91
+ raise RuntimeUnavailableError, "system manager job outcome is unavailable"
92
+ end
93
+ sleep 0.01
94
+ end
95
+ rescue SystemCallError, IOError
96
+ raise RuntimeUnavailableError, "system manager operation is unavailable"
97
+ ensure
98
+ [output_reader, output_writer, error_reader, error_writer].compact.each do |stream|
99
+ stream.close unless stream.closed?
100
+ end
101
+ if pid
102
+ begin
103
+ Process.kill("KILL", pid)
104
+ rescue Errno::ESRCH
105
+ nil
106
+ ensure
107
+ begin
108
+ Process.waitpid(pid)
109
+ rescue Errno::ECHILD
110
+ nil
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
116
+
117
+ def initialize(slice_unit:, service_unit:, command: Command.new)
118
+ unless slice_unit.is_a?(String) && service_unit.is_a?(String) &&
119
+ UNIT.match?(slice_unit) && UNIT.match?(service_unit) &&
120
+ slice_unit.end_with?(".slice") && service_unit.end_with?(".service") &&
121
+ !%w[system.slice user.slice machine.slice].include?(slice_unit)
122
+ raise ArgumentError, "execution scope requires fixed dedicated slice/service units"
123
+ end
124
+ @slice_unit, @service_unit, @command = slice_unit, service_unit, command
125
+ @slice_ancestors = []
126
+ parent = slice_unit
127
+ loop do
128
+ stem = parent.delete_suffix(".slice")
129
+ parent = stem.include?("-") ? stem.rpartition("-").first + ".slice" : "-.slice"
130
+ @slice_ancestors << parent
131
+ break if parent == "-.slice"
132
+ end
133
+ end
134
+
135
+ def inspect_units
136
+ slice = show(@slice_unit)
137
+ service = show(@service_unit, properties: SERVICE_PROPERTIES)
138
+ unless slice["LoadState"] == "loaded" && service["LoadState"] == "loaded" &&
139
+ service["Slice"] == @slice_unit
140
+ raise RuntimeUnavailableError, "fixed execution units are missing or their placement changed"
141
+ end
142
+ {"slice" => slice, "service" => service}
143
+ end
144
+
145
+ def start_slice
146
+ operate("start", @slice_unit)
147
+ end
148
+
149
+ def inspect_activation
150
+ inspect_units
151
+ slice = typed_properties(unit: @slice_unit, interface: "Unit", signatures: ACTIVATION_UNIT_SIGNATURES)
152
+ service = typed_properties(unit: @service_unit, interface: "Unit", signatures: ACTIVATION_UNIT_SIGNATURES)
153
+ service.merge!(typed_properties(unit: @service_unit, interface: "Service", signatures: ACTIVATION_SERVICE_SIGNATURES))
154
+ unless slice.fetch("Id") == @slice_unit && service.fetch("Id") == @service_unit &&
155
+ slice.fetch("LoadState") == "loaded" && service.fetch("LoadState") == "loaded" && service.fetch("Slice") == @slice_unit
156
+ raise RuntimeUnavailableError, "fixed activation escaped its parent slice"
157
+ end
158
+ {"slice" => slice, "service" => service}
159
+ end
160
+
161
+ def inspect_profile
162
+ # `show` loads installed metadata before typed reads; it starts no unit.
163
+ inspect_units
164
+ slice = typed_properties(unit: @slice_unit, interface: "Unit", signatures: UNIT_GRAPH_SIGNATURES)
165
+ service = typed_properties(unit: @service_unit, interface: "Unit", signatures: UNIT_GRAPH_SIGNATURES).merge(
166
+ typed_properties(unit: @service_unit, interface: "Service", signatures: SERVICE_EXEC_SIGNATURES))
167
+ ancestors = @slice_ancestors.to_h do |unit|
168
+ show(unit)
169
+ [unit, typed_properties(unit: unit, interface: "Unit", signatures: UNIT_GRAPH_SIGNATURES)]
170
+ end
171
+ {"slice" => slice, "service" => service, "ancestors" => ancestors, "unit_paths" => unit_paths, "prerequisites" => inspect_prerequisites(service)}
172
+ end
173
+
174
+ def unit_paths
175
+ bytes = @command.call([BUSCTL, "--system", "--no-pager", "--json=short", "--auto-start=no",
176
+ "--allow-interactive-authorization=no", "get-property", "org.freedesktop.systemd1",
177
+ "/org/freedesktop/systemd1", "org.freedesktop.systemd1.Manager", "UnitPath"], timeout: 5)
178
+ unless bytes.is_a?(String) && bytes.bytesize.between?(1, Command::LIMIT) && bytes.lines.size == 1
179
+ raise RuntimeUnavailableError, "system manager lookup paths are unavailable"
180
+ end
181
+ value = JSON.parse(bytes)
182
+ unless value.is_a?(Hash) && value.keys.sort == %w[data type] && value["type"] == "as" &&
183
+ value["data"].is_a?(Array) && value["data"].size.between?(1, 64) &&
184
+ value["data"].all? { |path| path.is_a?(String) && path.start_with?("/") && !path.include?("\0") && File.expand_path(path) == path }
185
+ raise RuntimeUnavailableError, "system manager lookup paths differ"
186
+ end
187
+ value.fetch("data")
188
+ rescue JSON::ParserError, KeyError
189
+ raise RuntimeUnavailableError, "system manager lookup paths are malformed"
190
+ end
191
+
192
+ # Typed reads only, against the existing system manager. Property sets
193
+ # and unit identities are owner-selected, never request-controlled.
194
+ def typed_properties(unit:, interface:, signatures:)
195
+ unless [@slice_unit, @service_unit, *@slice_ancestors, *@prerequisite_units.to_a].include?(unit) &&
196
+ %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) &&
197
+ signatures.all? { |key, value|
198
+ allowed = case interface
199
+ when "Unit" then UNIT_GRAPH_SIGNATURES.merge(ACTIVATION_UNIT_SIGNATURES)
200
+ when "Service" then SERVICE_EXEC_SIGNATURES.merge(ACTIVATION_SERVICE_SIGNATURES)
201
+ when "Mount" then MOUNT_SIGNATURES
202
+ end
203
+ allowed[key] == value
204
+ } && !signatures.empty?
205
+ raise ArgumentError, "typed inspection requires fixed unit properties"
206
+ end
207
+ object = "/org/freedesktop/systemd1/unit/" + unit.bytes.map { |byte|
208
+ ((byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) || (byte >= 48 && byte <= 57)) ? byte.chr : "_%02x" % byte
209
+ }.join
210
+ bytes = @command.call([BUSCTL, "--system", "--no-pager", "--json=short", "--auto-start=no",
211
+ "--allow-interactive-authorization=no", "get-property", "org.freedesktop.systemd1", object,
212
+ "org.freedesktop.systemd1.#{interface}", *signatures.keys], timeout: 5)
213
+ unless bytes.is_a?(String) && bytes.bytesize.between?(1, Command::LIMIT)
214
+ raise RuntimeUnavailableError, "typed manager properties are unavailable"
215
+ end
216
+ lines = bytes.lines
217
+ unless lines.size == signatures.size
218
+ raise RuntimeUnavailableError, "typed manager property set is incomplete"
219
+ end
220
+ signatures.to_a.zip(lines).to_h do |(key, signature), line|
221
+ value = JSON.parse(line)
222
+ unless value.is_a?(Hash) && value.keys.sort == %w[data type] && value["type"] == signature && typed_value?(signature, value["data"])
223
+ raise RuntimeUnavailableError, "typed manager property signature differs"
224
+ end
225
+ [key, value.fetch("data")]
226
+ end
227
+ rescue JSON::ParserError, KeyError
228
+ raise RuntimeUnavailableError, "typed manager properties are malformed"
229
+ end
230
+
231
+ def start_service
232
+ operate("start", @service_unit, timeout: 45)
233
+ end
234
+
235
+ def stop_slice
236
+ operate("stop", @slice_unit)
237
+ end
238
+
239
+ def stop_service
240
+ operate("stop", @service_unit)
241
+ end
242
+
243
+ private
244
+
245
+ def inspect_prerequisites(service)
246
+ @prerequisite_units = service.values_at("Requires", "Wants", "After").flatten.uniq - [@slice_unit]
247
+ unless @prerequisite_units.all? { |unit| unit == "systemd-journald.socket" ||
248
+ unit.is_a?(String) && unit.bytesize.between?(7, 255) && unit.end_with?(".mount") && !unit.match?(/[\s\/\0]/) }
249
+ raise RuntimeUnavailableError, "execution service has an unverified implicit prerequisite"
250
+ end
251
+ @prerequisite_units.to_h do |unit|
252
+ value = typed_properties(unit: unit, interface: "Unit", signatures: UNIT_STATE_SIGNATURES)
253
+ value.merge!(typed_properties(unit: unit, interface: "Mount", signatures: MOUNT_SIGNATURES)) if unit.end_with?(".mount")
254
+ [unit, value]
255
+ end
256
+ end
257
+
258
+ def typed_value?(signature, value)
259
+ string = ->(item) { item.is_a?(String) && !item.include?("\0") }
260
+ unsigned = ->(item, bits) { item.is_a?(Integer) && item >= 0 && item < (1 << bits) }
261
+ signed = ->(item) { item.is_a?(Integer) && item >= -(1 << 31) && item < (1 << 31) }
262
+ strings = ->(items) { items.is_a?(Array) && items.all? { |item| string.call(item) } }
263
+ case signature
264
+ when "s" then string.call(value)
265
+ when "(uo)" then value.is_a?(Array) && value.size == 2 && unsigned.call(value.first, 32) && string.call(value.last) && value.last.start_with?("/")
266
+ when "a(ss)"
267
+ value.is_a?(Array) && value.all? { |item| item.is_a?(Array) && item.size == 2 && item.all? { |part| string.call(part) } }
268
+ when "a(ssba(ss))", "a(sba(ss))"
269
+ strings_count = signature == "a(ssba(ss))" ? 2 : 1
270
+ value.is_a?(Array) && value.all? { |item| item.is_a?(Array) && item.size == strings_count + 2 &&
271
+ item.first(strings_count).all? { |part| string.call(part) } && [true, false].include?(item[strings_count]) &&
272
+ typed_value?("a(ss)", item.last) }
273
+ when "b" then value == true || value == false
274
+ when "t" then unsigned.call(value, 64)
275
+ when "u" then unsigned.call(value, 32)
276
+ when "as" then strings.call(value)
277
+ when "(aiai)"
278
+ value.is_a?(Array) && value.size == 2 && value.all? { |items| items.is_a?(Array) && items.all? { |item| signed.call(item) } }
279
+ when "(bas)"
280
+ value.is_a?(Array) && value.size == 2 && [true, false].include?(value.first) && strings.call(value.last)
281
+ when "a(sb)"
282
+ value.is_a?(Array) && value.all? { |item| item.is_a?(Array) && item.size == 2 && string.call(item.first) && [true, false].include?(item.last) }
283
+ when "a(ssbt)"
284
+ value.is_a?(Array) && value.all? { |item| item.is_a?(Array) && item.size == 4 &&
285
+ string.call(item[0]) && string.call(item[1]) && [true, false].include?(item[2]) && unsigned.call(item[3], 64) }
286
+ when "a(sasasttttuii)"
287
+ value.is_a?(Array) && value.all? { |item| item.is_a?(Array) && item.size == 10 &&
288
+ string.call(item[0]) && strings.call(item[1]) && strings.call(item[2]) &&
289
+ item[3..6].all? { |number| unsigned.call(number, 64) } && unsigned.call(item[7], 32) &&
290
+ signed.call(item[8]) && signed.call(item[9]) }
291
+ else false
292
+ end
293
+ end
294
+
295
+ def operate(verb, unit, timeout: 30)
296
+ @command.call([SYSTEMCTL, "--system", "--no-pager", "--no-ask-password", verb, "--", unit], timeout: timeout)
297
+ true
298
+ end
299
+
300
+ def show(unit, properties: PROPERTIES)
301
+ bytes = @command.call([SYSTEMCTL, "--system", "--no-pager", "--no-ask-password", "show",
302
+ "--all", "--property=#{properties.join(',')}", "--", unit], timeout: 5)
303
+ unless bytes.is_a?(String) && bytes.bytesize.between?(1, Command::LIMIT)
304
+ raise RuntimeUnavailableError, "fixed unit properties are unavailable"
305
+ end
306
+ expected = properties
307
+ properties = {}
308
+ bytes.each_line do |line|
309
+ key, value = line.chomp.split("=", 2)
310
+ unless expected.include?(key) && value && !properties.key?(key) && !value.include?("\0")
311
+ raise RuntimeUnavailableError, "fixed unit property response is malformed"
312
+ end
313
+ properties[key] = value
314
+ end
315
+ unless properties.keys.sort == expected.sort && properties["Id"] == unit
316
+ raise RuntimeUnavailableError, "fixed system manager unit identity differs"
317
+ end
318
+ properties
319
+ end
320
+ end
321
+ end
322
+ end
323
+ 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.2.0"
6
6
  end
7
7
  end
data/lib/ace/runtime.rb CHANGED
@@ -11,6 +11,8 @@ 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"
14
16
 
15
17
  module Ace
16
18
  module Runtime
data/native/README.md ADDED
@@ -0,0 +1,69 @@
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 and json-c parser.
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
+ and `libjson-c-dev` build packages. The executable requires the matching system
11
+ `libjson-c5`, 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; payload argv,
37
+ cwd and environment come from the root-controlled mapping after authority release.
38
+ The map grants no UID switching and the binary is never privileged.
39
+
40
+ The root-installed authority, launcher and native server units require:
41
+
42
+ ```ini
43
+ User=<their distinct mapped account>
44
+ NoNewPrivileges=yes
45
+ CapabilityBoundingSet=
46
+ AmbientCapabilities=
47
+ ```
48
+
49
+ Worker children inherit the server's empty bounds and no-new-privileges policy.
50
+ ACE checks all real/effective/saved/filesystem IDs, supplementary groups, every
51
+ capability set and `NoNewPrivs` from kernel status for all mapped participants,
52
+ including the pinned server. CAP_DAC_OVERRIDE/CAP_SETUID are refused, as is an
53
+ exec context that could gain capabilities or setuid privilege. Yama=2 remains
54
+ mandatory; an empty capability set alone does not stop same-UID injection.
55
+
56
+ The bootstrap sets non-dumpable before connecting, requires enforced Yama
57
+ `ptrace_scope=2`, requires empty Inh/Prm/Eff/Bnd/Amb capability sets and NoNewPrivs=1, verifies
58
+ worker IDs/groups and exact pinned server birth/parent, authenticates authority
59
+ SO_PEERCRED and protected socket ancestry, and waits at most 30 seconds on one
60
+ stream. It never reconnects or executes after pre-release EOF, malformed input
61
+ or timeout. A committed release whose response is lost remains potentially
62
+ executed; public inspection must retain ownership rather than resend release.
63
+
64
+ An ordinary Docker kernel with Yama=0 can verify compilation and fail-closed
65
+ policy rejection. It cannot demonstrate protected launch acceptance. A real
66
+ isolated Linux deployment enforcing the complete policy is required for that
67
+ positive proof; installing this gem does not change host sysctls or install OS
68
+ service/account policy. Downstream gad.8 provisions those requirements and
69
+ consumes this shipped build mechanism.
@@ -0,0 +1,18 @@
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
+ cflags=$(env -i PATH=/usr/bin:/bin /usr/bin/pkg-config --cflags json-c)
12
+ libs=$(env -i PATH=/usr/bin:/bin /usr/bin/pkg-config --libs json-c)
13
+ # pkg-config values come only from the installed system development package.
14
+ # shellcheck disable=SC2086
15
+ env -i PATH=/usr/bin:/bin /usr/bin/cc -std=c11 -O2 -D_FORTIFY_SOURCE=3 \
16
+ -fstack-protector-strong -fPIE -pie -Wl,-z,relro,-z,now \
17
+ -Wall -Wextra -Werror $cflags "$source_dir/worker_gate.c" $libs -o "$build_dir/ace-worker-gate"
18
+ install -m 0755 "$build_dir/ace-worker-gate" "$1"
@@ -0,0 +1,199 @@
1
+ #define _GNU_SOURCE
2
+ #include <errno.h>
3
+ #include <fcntl.h>
4
+ #include <json-c/json.h>
5
+ #include <poll.h>
6
+ #include <stdio.h>
7
+ #include <stdlib.h>
8
+ #include <string.h>
9
+ #include <sys/prctl.h>
10
+ #include <sys/socket.h>
11
+ #include <sys/stat.h>
12
+ #include <sys/un.h>
13
+ #include <time.h>
14
+ #include <unistd.h>
15
+ #include <grp.h>
16
+
17
+ #define MAP_PATH "/etc/ace/assignment-authorities.json"
18
+ #define LIMIT 65536
19
+ static _Noreturn void deny(const char *why) { fprintf(stderr, "ace-worker-gate: %s\n", why); exit(125); }
20
+ static double now(void) { struct timespec ts; if (clock_gettime(CLOCK_MONOTONIC,&ts)) deny("clock unavailable"); return ts.tv_sec + ts.tv_nsec / 1e9; }
21
+ static int token(const char *s) {
22
+ if (!s || !*s || strlen(s)>128) return 0;
23
+ for (const unsigned char *p=(const unsigned char *)s; *p; p++)
24
+ if (!((*p>='a'&&*p<='z')||(*p>='A'&&*p<='Z')||(*p>='0'&&*p<='9')||*p=='_'||*p=='-'||*p=='.')) return 0;
25
+ return 1;
26
+ }
27
+ static json_object *field(json_object *o,const char *k,enum json_type t) {
28
+ json_object *v=NULL;
29
+ if (!o || !json_object_object_get_ex(o,k,&v) || !json_object_is_type(v,t)) deny("invalid protected schema");
30
+ return v;
31
+ }
32
+ static const char *str(json_object *o,const char *k) { return json_object_get_string(field(o,k,json_type_string)); }
33
+ static int number(json_object *o,const char *k) {
34
+ int64_t n=json_object_get_int64(field(o,k,json_type_int));
35
+ if (n<=0 || n>2147483647) deny("invalid principal identity");
36
+ return (int)n;
37
+ }
38
+ static void trusted_path(const char *path,int dir,uid_t owner) {
39
+ char copy[4096]; size_t n=strlen(path); struct stat st;
40
+ if (!n || n>=sizeof(copy) || path[0]!='/' || strstr(path,"//") || strstr(path,"/../") || strstr(path,"/./") ||
41
+ !strcmp(path+n-1,"/") || !strcmp(path+n-(n>=3?3:n),"/..")) deny("invalid protected path");
42
+ memcpy(copy,path,n+1);
43
+ for (size_t i=1;i<=n;i++) if (copy[i]=='/' || i==n) {
44
+ char c=copy[i]; copy[i]=0;
45
+ if (lstat(copy,&st) || (i==n ? st.st_uid!=owner : (st.st_uid!=0 && st.st_uid!=owner)) || (st.st_mode&022) || S_ISLNK(st.st_mode) ||
46
+ (i<n&&!S_ISDIR(st.st_mode))) deny("protected path is writable or replaced");
47
+ copy[i]=c;
48
+ }
49
+ if (dir && !S_ISDIR(st.st_mode)) deny("protected directory unavailable");
50
+ if (!dir && !S_ISREG(st.st_mode)) deny("protected file unavailable");
51
+ }
52
+ static void root_path(const char *path,int dir) { trusted_path(path,dir,0); }
53
+ static char *read_file(const char *path,size_t max) {
54
+ int fd=open(path,O_RDONLY|O_CLOEXEC|O_NOFOLLOW); if(fd<0) deny("policy file unavailable");
55
+ char *s=calloc(max+1,1); if(!s) deny("allocation failed"); size_t n=0;
56
+ while(n<max) { ssize_t r=read(fd,s+n,max-n); if(r<0&&errno==EINTR) continue; if(r<0) deny("policy read failed"); if(!r) break; n+=(size_t)r; }
57
+ char extra; if(n==max&&read(fd,&extra,1)!=0) deny("policy file oversized"); close(fd); return s;
58
+ }
59
+ static void policy(void) {
60
+ char *s=read_file("/proc/sys/kernel/yama/ptrace_scope",16);
61
+ if(strcmp(s,"2\n")&&strcmp(s,"2")) deny("Linux Yama ptrace_scope=2 is required");
62
+ free(s);
63
+ }
64
+ static void credentials(pid_t pid,int uid,int gid,json_object *groups) {
65
+ char path[64]; snprintf(path,sizeof(path),"/proc/%d/status",pid); char *s=read_file(path,16384);
66
+ int got_uid=0,got_gid=0,caps=0,got_groups=0,no_new_privs=0; char *save=NULL;
67
+ for(char *line=strtok_r(s,"\n",&save);line;line=strtok_r(NULL,"\n",&save)) {
68
+ unsigned a,b,c,d; unsigned long long cap;
69
+ if(sscanf(line,"Uid: %u %u %u %u",&a,&b,&c,&d)==4) { if(a!=(unsigned)uid||b!=a||c!=a||d!=a) deny("UID changed"); got_uid=1; }
70
+ if(sscanf(line,"Gid: %u %u %u %u",&a,&b,&c,&d)==4) { if(a!=(unsigned)gid||b!=a||c!=a||d!=a) deny("GID changed"); got_gid=1; }
71
+ if(sscanf(line,"NoNewPrivs: %u",&a)==1) { if(a!=1)deny("NoNewPrivs=1 is required"); no_new_privs=1; }
72
+ const char *names[]={"CapInh:","CapPrm:","CapEff:","CapBnd:","CapAmb:"};
73
+ for(int i=0;i<5;i++) if(!strncmp(line,names[i],7)) {
74
+ if(sscanf(line+7,"%llx",&cap)!=1 || cap!=0) deny("all capability sets must be empty");
75
+ caps|=1<<i;
76
+ }
77
+ if(!strncmp(line,"Groups:",7)) {
78
+ size_t count=json_object_array_length(groups),index=0; char *end=NULL,*p=line+7;
79
+ while(1) { while(*p==' '||*p=='\t')p++; if(!*p)break; errno=0; long group=strtol(p,&end,10);
80
+ if(errno||end==p||index>=count||!json_object_is_type(json_object_array_get_idx(groups,index),json_type_int)||
81
+ group!=json_object_get_int64(json_object_array_get_idx(groups,index))) deny("supplementary groups changed");
82
+ p=end; index++;
83
+ }
84
+ if(index!=count) deny("supplementary groups changed");
85
+ got_groups=1;
86
+ }
87
+ }
88
+ free(s); if(!got_uid||!got_gid||!got_groups||!no_new_privs||caps!=31)deny("kernel credentials unavailable");
89
+ }
90
+ static void lineage(json_object *native) {
91
+ json_object *server=field(native,"server_identity",json_type_object); int pid=number(server,"pid");
92
+ if(getppid()!=pid) deny("gate is not the original server child");
93
+ char path[64]; snprintf(path,sizeof(path),"/proc/%d/stat",pid); char *s=read_file(path,16384);
94
+ char *end=strrchr(s,')'); if(!end||end[1]!=' ') deny("server birth unavailable");
95
+ char *save=NULL,*word=strtok_r(end+2," ", &save); int i=3; const char *ticks=NULL;
96
+ while(word) { if(i==22){ticks=word;break;} i++;word=strtok_r(NULL," ",&save); }
97
+ if(!ticks||strspn(ticks,"0123456789")!=strlen(ticks))deny("server birth unavailable");
98
+ char *boot=read_file("/proc/sys/kernel/random/boot_id",128); boot[strcspn(boot,"\n")]=0;
99
+ char birth[256]; snprintf(birth,sizeof(birth),"linux:%s:%s",boot,ticks);
100
+ if(strcmp(birth,str(server,"started_at")))deny("server incarnation changed");
101
+ free(boot);free(s);
102
+ credentials(pid,number(server,"uid"),number(server,"gid"),field(server,"groups",json_type_array));
103
+ }
104
+ static void wait_fd(int fd,short events,double deadline) {
105
+ struct pollfd p={.fd=fd,.events=events}; int r;
106
+ do {
107
+ double remaining=deadline-now(); if(remaining<=0)deny("authority deadline expired");
108
+ r=poll(&p,1,(int)(remaining*1000));
109
+ }while(r<0&&errno==EINTR);
110
+ if(r<=0 || (p.revents&(POLLERR|POLLNVAL)))deny("authority stream unavailable");
111
+ }
112
+ static void send_frame(int fd,json_object *o,double deadline) {
113
+ const char *s=json_object_to_json_string_ext(o,JSON_C_TO_STRING_PLAIN); size_t n=strlen(s);
114
+ if(n+1>LIMIT)deny("request oversized");
115
+ char *frame=malloc(n+2); if(!frame)deny("allocation failed");
116
+ memcpy(frame,s,n);frame[n++]='\n';size_t offset=0;
117
+ while(offset<n) { wait_fd(fd,POLLOUT,deadline); ssize_t r=send(fd,frame+offset,n-offset,MSG_NOSIGNAL);
118
+ if(r<0&&(errno==EINTR||errno==EAGAIN))continue;
119
+ if(r<=0)deny("authority write failed");
120
+ offset+=(size_t)r; }
121
+ free(frame);
122
+ }
123
+ static json_object *receive(int fd,double deadline) {
124
+ char *s=calloc(LIMIT+1,1); if(!s)deny("allocation failed"); size_t n=0;
125
+ while(n<LIMIT) { wait_fd(fd,POLLIN,deadline); ssize_t r=recv(fd,s+n,1,0);
126
+ if(r<0&&(errno==EINTR||errno==EAGAIN))continue;
127
+ if(r<=0)deny("authority EOF before permission");
128
+ if(s[n++]=='\n') { json_tokener *t=json_tokener_new();json_object *o=json_tokener_parse_ex(t,s,(int)n);
129
+ if(!o||json_tokener_get_error(t)!=json_tokener_success||json_tokener_get_parse_end(t)!=n||!json_object_is_type(o,json_type_object))deny("invalid authority frame");
130
+ json_tokener_free(t);free(s);return o; }
131
+ }
132
+ deny("authority frame oversized"); return NULL;
133
+ }
134
+ int main(int argc,char **argv) {
135
+ if(prctl(PR_SET_DUMPABLE,0,0,0,0))deny("non-dumpable gate unavailable");
136
+ if(argc!=3||!token(argv[1])||!token(argv[2]))deny("expected fixed mapping and correlation ticket");
137
+ policy();root_path(MAP_PATH,0);char *bytes=read_file(MAP_PATH,LIMIT);
138
+ json_tokener *parser=json_tokener_new();json_object *map=json_tokener_parse_ex(parser,bytes,(int)strlen(bytes));
139
+ if(!map||json_tokener_get_error(parser)!=json_tokener_success||json_tokener_get_parse_end(parser)!=strlen(bytes))deny("invalid deployment map");
140
+ json_tokener_free(parser);free(bytes);
141
+ if(strcmp(str(map,"schema"),"ace.assign.authorities/v1"))deny("unsupported deployment map");
142
+ json_object *mapping=field(field(map,"launch_mappings",json_type_object),argv[1],json_type_object);
143
+ json_object *service=field(field(map,"authorities",json_type_object),str(mapping,"authority_id"),json_type_object);
144
+ int worker=number(mapping,"worker_uid"),launcher=number(mapping,"launcher_uid"),authority=number(service,"uid");
145
+ if(worker==launcher||worker==authority||authority==launcher)deny("principals are not distinct");
146
+ credentials(getpid(),worker,number(mapping,"worker_gid"),field(mapping,"worker_groups",json_type_array));
147
+ lineage(field(mapping,"native",json_type_object));
148
+ const char *bootstrap=str(mapping,"bootstrap");root_path(bootstrap,0);
149
+ struct stat installed,self;
150
+ if(stat(bootstrap,&installed)||stat("/proc/self/exe",&self)||installed.st_dev!=self.st_dev||installed.st_ino!=self.st_ino||
151
+ (installed.st_mode&06000))deny("gate executable differs from installed bootstrap");
152
+ const char *path=str(service,"socket_path");char parent[4096];if(strlen(path)>=sizeof(parent))deny("socket path too long");
153
+ strcpy(parent,path);char *slash=strrchr(parent,'/');if(!slash||slash==parent)deny("invalid socket path");*slash=0;
154
+ /* Only root/the trusted authority may own the non-writable ancestry. */
155
+ trusted_path(parent,1,(uid_t)authority);
156
+ struct stat directory,endpoint;if(lstat(parent,&directory)||!S_ISDIR(directory.st_mode)||directory.st_uid!=(uid_t)authority||
157
+ (directory.st_mode&022)||lstat(path,&endpoint)||!S_ISSOCK(endpoint.st_mode)||endpoint.st_uid!=(uid_t)authority)deny("unsafe authority endpoint");
158
+ int fd=socket(AF_UNIX,SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK,0);if(fd<0)deny("socket unavailable");
159
+ struct sockaddr_un address={.sun_family=AF_UNIX};if(strlen(path)>=sizeof(address.sun_path))deny("socket path too long");strcpy(address.sun_path,path);
160
+ double deadline=now()+30;
161
+ if(connect(fd,(struct sockaddr *)&address,sizeof(address))<0) {
162
+ if(errno!=EINPROGRESS&&errno!=EAGAIN)deny("authority connection failed");
163
+ wait_fd(fd,POLLOUT,deadline);
164
+ int error=0;socklen_t length=sizeof(error);if(getsockopt(fd,SOL_SOCKET,SO_ERROR,&error,&length)||error)deny("authority connection failed");
165
+ }
166
+ struct ucred peer;socklen_t length=sizeof(peer);
167
+ if(getsockopt(fd,SOL_SOCKET,SO_PEERCRED,&peer,&length)||peer.uid!=(uid_t)authority||peer.gid!=(gid_t)number(service,"gid"))deny("authority peer differs");
168
+ credentials(peer.pid,authority,number(service,"gid"),field(service,"groups",json_type_array));
169
+ struct stat after;if(lstat(path,&after)||after.st_dev!=endpoint.st_dev||after.st_ino!=endpoint.st_ino||after.st_uid!=endpoint.st_uid)deny("authority endpoint replaced");
170
+ json_object *request=json_object_new_object(),*params=json_object_new_object();
171
+ json_object_object_add(request,"version",json_object_new_int(1));json_object_object_add(request,"operation",json_object_new_string("gate_ready"));
172
+ json_object_object_add(request,"mutation_id",NULL);json_object_object_add(request,"project_id",json_object_new_string(str(mapping,"project_id")));
173
+ json_object_object_add(params,"mapping_id",json_object_new_string(argv[1]));json_object_object_add(params,"launch_ticket",json_object_new_string(argv[2]));
174
+ json_object_object_add(request,"params",params);send_frame(fd,request,deadline);json_object_put(request);
175
+ json_object *ready=receive(fd,deadline);
176
+ if(strcmp(str(ready,"status"),"ok")||strcmp(str(field(ready,"data",json_type_object),"phase"),"ready"))deny("gate not admitted");
177
+ json_object_put(ready);
178
+ json_object *permission=receive(fd,deadline);
179
+ if(strcmp(str(permission,"operation"),"release")||strcmp(str(permission,"launch_ticket"),argv[2])||
180
+ !token(str(permission,"attempt_id"))||!token(str(permission,"assignment_id"))||!token(str(permission,"journal_commit")))deny("release permission differs");
181
+ if(json_object_get_int64(field(permission,"generation",json_type_int))<1)deny("invalid release generation");
182
+ policy();lineage(field(mapping,"native",json_type_object));credentials(peer.pid,authority,number(service,"gid"),field(service,"groups",json_type_array));
183
+ json_object *arguments=field(mapping,"worker_argv",json_type_array);size_t count=json_object_array_length(arguments);
184
+ if(!count||count>64)deny("invalid fixed payload");
185
+ char **payload=calloc(count+1,sizeof(char *));if(!payload)deny("allocation failed");
186
+ for(size_t i=0;i<count;i++) {json_object *value=json_object_array_get_idx(arguments,i);if(!json_object_is_type(value,json_type_string))deny("invalid fixed argv");payload[i]=(char *)json_object_get_string(value);}
187
+ root_path(payload[0],0);struct stat program;if(stat(payload[0],&program)||(program.st_mode&06000)||!(program.st_mode&0111))deny("unsafe payload executable");
188
+ if(clearenv())deny("environment reset failed");
189
+ json_object *env=field(mapping,"worker_env",json_type_object);
190
+ json_object_object_foreach(env,key,value) {
191
+ if(!json_object_is_type(value,json_type_string)||!strncmp(key,"LD_",3)||!strncmp(key,"DYLD_",5)||!strncmp(key,"RUBY",4)||!strncmp(key,"BUNDLE",6)||!strncmp(key,"PYTHON",6))deny("unsafe fixed environment");
192
+ if(setenv(key,json_object_get_string(value),1))deny("environment setup failed");
193
+ }
194
+ if(setenv("ACE_ASSIGN_ATTEMPT_ID",str(permission,"attempt_id"),1)||setenv("ACE_ASSIGN_ASSIGNMENT_ID",str(permission,"assignment_id"),1)||
195
+ setenv("ACE_ASSIGN_LAUNCH_MAPPING",argv[1],1))deny("authority context unavailable");
196
+ if(chdir(str(mapping,"worker_cwd")))deny("fixed worker directory unavailable");
197
+ close(fd);
198
+ execv(payload[0],payload);deny("fixed worker execution failed");
199
+ }