ace-runtime 0.2.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -0
- data/docs/usage.md +55 -0
- data/lib/ace/runtime/molecules/codex_runtime_installation.rb +130 -0
- data/lib/ace/runtime/molecules/execution_boot_baseline.rb +134 -0
- data/lib/ace/runtime/molecules/execution_network_selection.rb +85 -0
- data/lib/ace/runtime/molecules/execution_unit_installation.rb +375 -21
- data/lib/ace/runtime/molecules/kernel_view_topology.rb +133 -0
- data/lib/ace/runtime/molecules/protected_artifact_set.rb +74 -9
- data/lib/ace/runtime/molecules/protected_socket.rb +24 -2
- data/lib/ace/runtime/molecules/protected_task_context_entry.rb +162 -0
- data/lib/ace/runtime/molecules/protected_worker_entry.rb +32 -0
- data/lib/ace/runtime/molecules/readiness_configuration.rb +154 -0
- data/lib/ace/runtime/molecules/readiness_runtime.rb +94 -0
- data/lib/ace/runtime/molecules/server_resource_observation.rb +183 -0
- data/lib/ace/runtime/molecules/systemd_scope_manager.rb +103 -15
- data/lib/ace/runtime/version.rb +1 -1
- data/lib/ace/runtime.rb +1 -0
- data/native/README.md +61 -6
- data/native/build-worker-gate +4 -2
- data/native/worker_gate.c +206 -20
- metadata +11 -2
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require_relative "server_resource_observation"
|
|
3
|
+
|
|
4
|
+
module Ace
|
|
5
|
+
module Runtime
|
|
6
|
+
module Molecules
|
|
7
|
+
# Pure verification of the fixed supported unit's live report. Installed
|
|
8
|
+
# profile verification and original host proof authentication are separate.
|
|
9
|
+
class KernelViewTopology
|
|
10
|
+
VIEWS = ServerResourceObservation::VIEW_PATHS
|
|
11
|
+
MOUNT_FIELDS = ServerResourceObservation::MOUNT_FIELDS.sort.freeze
|
|
12
|
+
READONLY = %w[/proc/sys /sys /sys/fs/cgroup /dev /dev/shm].freeze
|
|
13
|
+
APIS = {"/proc" => "proc", "/dev/pts" => "devpts", "/dev/mqueue" => "mqueue"}.freeze
|
|
14
|
+
|
|
15
|
+
def verify!(topology:, host_ipc:, authority_socket:, writable_resources:, host_devpts:, selected_devpts:)
|
|
16
|
+
object!(topology, %w[authority_socket_identity hook_ipc_namespace_identity ipc_namespace_identity mounts ptmx_identity ptmx_link views])
|
|
17
|
+
ipc = topology.fetch("ipc_namespace_identity")
|
|
18
|
+
namespace!(ipc)
|
|
19
|
+
namespace!(topology.fetch("hook_ipc_namespace_identity"))
|
|
20
|
+
namespace!(host_ipc)
|
|
21
|
+
refuse! unless ipc == topology.fetch("hook_ipc_namespace_identity") && ipc != host_ipc
|
|
22
|
+
socket = topology.fetch("authority_socket_identity")
|
|
23
|
+
refuse! unless socket.is_a?(Array) && socket.size == 3 && socket.all? { |n| n.is_a?(Integer) && n >= 0 } &&
|
|
24
|
+
socket.first(2).all?(&:positive?) && socket == authority_socket
|
|
25
|
+
mounts = topology.fetch("mounts")
|
|
26
|
+
refuse! unless mounts.is_a?(Array) && mounts.size.between?(1, 256)
|
|
27
|
+
mounts.each { |mount| mount!(mount) }
|
|
28
|
+
refuse! unless mounts.map { |row| row.fetch("mount_id") }.uniq.size == mounts.size
|
|
29
|
+
roots = mounts.select { |row| row.fetch("mountpoint") == "/" }
|
|
30
|
+
refuse! unless !roots.empty? && roots.all? { |row| readonly?(row) }
|
|
31
|
+
views = topology.fetch("views")
|
|
32
|
+
refuse! unless views.is_a?(Array) && views.size == VIEWS.size && views.map { |view| view.fetch("path") }.sort == VIEWS.sort
|
|
33
|
+
by_path = views.to_h do |view|
|
|
34
|
+
object!(view, %w[device inode mount_id path type])
|
|
35
|
+
refuse! unless view["type"] == "directory" && %w[device inode mount_id].all? { |key| positive?(view[key]) }
|
|
36
|
+
row = mounts.find { |mount| mount.fetch("mount_id") == view.fetch("mount_id") }
|
|
37
|
+
refuse! unless row && covers?(row.fetch("mountpoint"), view.fetch("path"))
|
|
38
|
+
[view.fetch("path"), row]
|
|
39
|
+
end
|
|
40
|
+
READONLY.each { |path| refuse! unless readonly?(by_path.fetch(path)) }
|
|
41
|
+
APIS.each do |path, filesystem|
|
|
42
|
+
row = by_path.fetch(path)
|
|
43
|
+
refuse! unless row.fetch("mountpoint") == path && row.fetch("root") == "/" && row.fetch("filesystem_type") == filesystem
|
|
44
|
+
end
|
|
45
|
+
object!(host_devpts, %w[device inode major_minor])
|
|
46
|
+
object!(selected_devpts, %w[device inode major_minor path ptmx_inode])
|
|
47
|
+
[host_devpts, selected_devpts].each do |instance|
|
|
48
|
+
refuse! unless %w[device inode].all? { |key| positive?(instance[key]) } &&
|
|
49
|
+
instance["major_minor"].is_a?(String) && instance["major_minor"].match?(/\A(?:0|[1-9][0-9]*):(?:0|[1-9][0-9]*)\z/)
|
|
50
|
+
end
|
|
51
|
+
refuse! unless positive?(selected_devpts["ptmx_inode"]) && path?(selected_devpts["path"])
|
|
52
|
+
pts = views.find { |view| view["path"] == "/dev/pts" }
|
|
53
|
+
pts_mount = by_path.fetch("/dev/pts")
|
|
54
|
+
refuse! unless pts.values_at("device", "inode") == selected_devpts.values_at("device", "inode") &&
|
|
55
|
+
pts_mount["major_minor"] == selected_devpts["major_minor"] &&
|
|
56
|
+
selected_devpts["device"] != host_devpts["device"] && selected_devpts["major_minor"] != host_devpts["major_minor"] &&
|
|
57
|
+
topology["ptmx_link"] == "pts/ptmx"
|
|
58
|
+
ptmx = topology.fetch("ptmx_identity")
|
|
59
|
+
object!(ptmx, %w[device inode mount_id rdev_major rdev_minor type])
|
|
60
|
+
refuse! unless ptmx["type"] == "character" && %w[device inode mount_id].all? { |key| positive?(ptmx[key]) } &&
|
|
61
|
+
ptmx["rdev_major"].is_a?(Integer) && ptmx["rdev_minor"].is_a?(Integer) &&
|
|
62
|
+
ptmx.values_at("rdev_major", "rdev_minor") == [5, 2] &&
|
|
63
|
+
ptmx.values_at("device", "inode") == selected_devpts.values_at("device", "ptmx_inode")
|
|
64
|
+
node_mount = mounts.find { |row| row["mount_id"] == ptmx["mount_id"] }
|
|
65
|
+
refuse! unless node_mount && readonly?(node_mount) &&
|
|
66
|
+
node_mount.values_at("mountpoint", "root", "filesystem_type", "major_minor") ==
|
|
67
|
+
["/dev/pts/ptmx", "/ptmx", "devpts", selected_devpts["major_minor"]]
|
|
68
|
+
hidden_host = mounts.select { |row| row["major_minor"] == host_devpts["major_minor"] }
|
|
69
|
+
hidden_host.each do |row|
|
|
70
|
+
refuse! unless row.values_at("mountpoint", "root", "filesystem_type") == ["/dev/pts", "/", "devpts"]
|
|
71
|
+
end
|
|
72
|
+
refuse! unless hidden_host.size <= 1
|
|
73
|
+
pts_layers = mounts.select { |row| row["mountpoint"] == "/dev/pts" }
|
|
74
|
+
refuse! unless (pts_layers - [pts_mount] - hidden_host).empty?
|
|
75
|
+
unless hidden_host.empty?
|
|
76
|
+
chain = []
|
|
77
|
+
parent = pts_mount
|
|
78
|
+
while parent["mountpoint"] == "/dev/pts"
|
|
79
|
+
refuse! if chain.include?(parent["mount_id"])
|
|
80
|
+
chain << parent.fetch("mount_id")
|
|
81
|
+
parent = mounts.find { |row| row["mount_id"] == parent["parent_id"] }
|
|
82
|
+
refuse! unless parent
|
|
83
|
+
end
|
|
84
|
+
refuse! unless hidden_host.all? { |row| chain.include?(row["mount_id"]) }
|
|
85
|
+
end
|
|
86
|
+
refuse! unless by_path.fetch("/dev").fetch("filesystem_type") == "tmpfs" && by_path.fetch("/dev").fetch("mountpoint") == "/dev"
|
|
87
|
+
mounts.each do |row|
|
|
88
|
+
next if hidden_host.include?(row)
|
|
89
|
+
next if readonly?(row)
|
|
90
|
+
api = APIS[row.fetch("mountpoint")]
|
|
91
|
+
next if api && row == by_path.fetch(row.fetch("mountpoint")) && row.fetch("filesystem_type") == api
|
|
92
|
+
refuse! if %w[/dev /proc /sys].any? { |root| covers?(root, row.fetch("mountpoint")) }
|
|
93
|
+
refuse! unless writable_resources.any? do |resource|
|
|
94
|
+
row.values_at("mountpoint", "major_minor", "root") == resource.values_at("view_path", "major_minor", "filesystem_path") &&
|
|
95
|
+
row.fetch("filesystem_type") == resource.fetch("filesystem_type")
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
%w[/tmp /var/tmp].each do |path|
|
|
99
|
+
row = by_path.fetch(path)
|
|
100
|
+
refuse! unless readonly?(row) || writable_resources.any? { |resource| resource["view_path"] == path && row["mountpoint"] == path }
|
|
101
|
+
end
|
|
102
|
+
true
|
|
103
|
+
rescue KeyError, TypeError, NoMethodError, ArgumentError
|
|
104
|
+
refuse!
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def mount!(row)
|
|
110
|
+
object!(row, MOUNT_FIELDS)
|
|
111
|
+
refuse! unless positive?(row["mount_id"]) && row["parent_id"].is_a?(Integer) && row["parent_id"] >= 0 &&
|
|
112
|
+
row["major_minor"].is_a?(String) && row["major_minor"].match?(/\A[0-9]+:[0-9]+\z/) &&
|
|
113
|
+
path?(row["root"]) && path?(row["mountpoint"]) && row["filesystem_type"].is_a?(String) &&
|
|
114
|
+
row["filesystem_type"].match?(/\A[a-zA-Z0-9_.-]+\z/) && row["options"].is_a?(Array) &&
|
|
115
|
+
row["options"].all? { |option| option.is_a?(String) && option.bytesize.between?(1, 128) } &&
|
|
116
|
+
row["options"].uniq == row["options"] && (row["options"] & %w[ro rw]).size == 1
|
|
117
|
+
end
|
|
118
|
+
def object!(value, fields)
|
|
119
|
+
refuse! unless value.is_a?(Hash) && value.keys.sort == fields.sort
|
|
120
|
+
end
|
|
121
|
+
def namespace!(value)
|
|
122
|
+
object!(value, %w[device inode])
|
|
123
|
+
refuse! unless value.values.all? { |number| positive?(number) }
|
|
124
|
+
end
|
|
125
|
+
def positive?(number) = number.is_a?(Integer) && number.positive?
|
|
126
|
+
def path?(path) = path.is_a?(String) && path.bytesize.between?(1, 4096) && path.start_with?("/") && !path.include?("\0") && File.expand_path(path) == path
|
|
127
|
+
def covers?(root, path) = root == "/" || root == path || path.start_with?(root + "/")
|
|
128
|
+
def readonly?(row) = row.fetch("options").include?("ro")
|
|
129
|
+
def refuse! = raise(RuntimeUnavailableError, "live kernel/resource view does not match the supported profile")
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -48,8 +48,17 @@ module Ace
|
|
|
48
48
|
def trusted_owner?(stat) = stat.uid.zero?
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
-
def initialize(protection: Protection.new)
|
|
51
|
+
def initialize(protection: Protection.new, file_limit: LIMIT, total_limit: TOTAL_LIMIT, count_limit: COUNT_LIMIT)
|
|
52
|
+
unless file_limit.is_a?(Integer) && total_limit.is_a?(Integer) &&
|
|
53
|
+
file_limit.positive? && total_limit.positive? && file_limit <= total_limit
|
|
54
|
+
raise ArgumentError, "invalid protected artifact byte budgets"
|
|
55
|
+
end
|
|
56
|
+
unless count_limit.is_a?(Integer) && count_limit.positive?
|
|
57
|
+
raise ArgumentError, "invalid protected artifact count budget"
|
|
58
|
+
end
|
|
52
59
|
@protection = protection
|
|
60
|
+
@file_limit, @total_limit = file_limit, total_limit
|
|
61
|
+
@count_limit = count_limit
|
|
53
62
|
end
|
|
54
63
|
|
|
55
64
|
def with
|
|
@@ -67,9 +76,9 @@ module Ace
|
|
|
67
76
|
raise RuntimeUnavailableError, "network artifact references conflict" unless previous == reference
|
|
68
77
|
return @contents.fetch(path)
|
|
69
78
|
end
|
|
70
|
-
raise RuntimeUnavailableError, "network artifact graph is oversized" if @references.size >=
|
|
79
|
+
raise RuntimeUnavailableError, "network artifact graph is oversized" if @references.size >= @count_limit
|
|
71
80
|
size = reference.fetch("bytes")
|
|
72
|
-
unless size.is_a?(Integer) && size.between?(1,
|
|
81
|
+
unless size.is_a?(Integer) && size.between?(1, @file_limit) && @total + size <= @total_limit
|
|
73
82
|
raise RuntimeUnavailableError, "network artifact bytes exceed bounds"
|
|
74
83
|
end
|
|
75
84
|
@protection.root_path!(path)
|
|
@@ -89,11 +98,66 @@ module Ace
|
|
|
89
98
|
bytes
|
|
90
99
|
end
|
|
91
100
|
|
|
101
|
+
# Trusted process handoff inside the same bounded verification. The
|
|
102
|
+
# duplicate refers to the authenticated retained inode, never a reopened
|
|
103
|
+
# pathname, and is closed before this callback can outlive the set.
|
|
104
|
+
def with_readonly_handle!(reference)
|
|
105
|
+
unless @handles && @references
|
|
106
|
+
raise RuntimeUnavailableError, "protected handle requires an active verification"
|
|
107
|
+
end
|
|
108
|
+
read!(reference)
|
|
109
|
+
verify_unchanged!
|
|
110
|
+
duplicate = @handles.fetch(reference.fetch("path")).fetch(:handle).dup
|
|
111
|
+
duplicate.close_on_exec = true
|
|
112
|
+
duplicate.rewind
|
|
113
|
+
begin
|
|
114
|
+
yield duplicate
|
|
115
|
+
ensure
|
|
116
|
+
verify_unchanged!
|
|
117
|
+
end
|
|
118
|
+
ensure
|
|
119
|
+
duplicate&.close unless duplicate&.closed?
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Fixed installer entrypoints have no caller-supplied digest. Derive the
|
|
123
|
+
# reference from the same protected descriptor whose bytes are retained.
|
|
124
|
+
def read_path!(path, limit: @file_limit)
|
|
125
|
+
raise ArgumentError, "invalid artifact byte limit" unless limit.is_a?(Integer) && limit.between?(1, @file_limit)
|
|
126
|
+
raise ArgumentError, "invalid fixed artifact path" unless path.is_a?(String) &&
|
|
127
|
+
path.start_with?("/") && !path.include?("\0") && File.expand_path(path) == path
|
|
128
|
+
if @references.key?(path)
|
|
129
|
+
reference = @references.fetch(path)
|
|
130
|
+
raise RuntimeUnavailableError, "fixed artifact exceeds bounds" if reference.fetch("bytes") > limit
|
|
131
|
+
return [@contents.fetch(path), reference].freeze
|
|
132
|
+
end
|
|
133
|
+
@protection.root_path!(path)
|
|
134
|
+
ancestors(path).reverse_each { |ancestor| pin!(ancestor, directory: true) }
|
|
135
|
+
handle = pin!(path, directory: false)
|
|
136
|
+
size = handle.stat.size
|
|
137
|
+
unless size.between?(1, limit) && @references.size < @count_limit && @total + size <= @total_limit
|
|
138
|
+
raise RuntimeUnavailableError, "fixed artifact exceeds bounds"
|
|
139
|
+
end
|
|
140
|
+
bytes = handle.read(size + 1)
|
|
141
|
+
unless bytes.is_a?(String) && bytes.bytesize == size
|
|
142
|
+
raise RuntimeUnavailableError, "fixed artifact length changed"
|
|
143
|
+
end
|
|
144
|
+
reference = {"path" => path.dup.freeze, "sha256" => Digest::SHA256.hexdigest(bytes).freeze,
|
|
145
|
+
"bytes" => size}.freeze
|
|
146
|
+
if @references.key?(path)
|
|
147
|
+
raise RuntimeUnavailableError, "fixed artifact references conflict" unless @references.fetch(path) == reference
|
|
148
|
+
else
|
|
149
|
+
@references[path] = reference
|
|
150
|
+
@contents[path] = bytes.freeze
|
|
151
|
+
@total += size
|
|
152
|
+
end
|
|
153
|
+
[@contents.fetch(path), reference].freeze
|
|
154
|
+
end
|
|
155
|
+
|
|
92
156
|
def verify_unchanged!
|
|
93
157
|
@handles.each do |path, entry|
|
|
94
158
|
handle = entry.fetch(:handle)
|
|
95
|
-
unless snapshot(handle.stat) == entry.fetch(:snapshot) &&
|
|
96
|
-
snapshot(File.lstat(path)) == entry.fetch(:snapshot)
|
|
159
|
+
unless snapshot(handle.stat, directory: entry.fetch(:directory)) == entry.fetch(:snapshot) &&
|
|
160
|
+
snapshot(File.lstat(path), directory: entry.fetch(:directory)) == entry.fetch(:snapshot)
|
|
97
161
|
raise RuntimeUnavailableError, "network artifact changed during verification"
|
|
98
162
|
end
|
|
99
163
|
@protection.verify!(path, handle, directory: entry.fetch(:directory))
|
|
@@ -126,10 +190,10 @@ module Ace
|
|
|
126
190
|
begin
|
|
127
191
|
handle.close_on_exec = true
|
|
128
192
|
@protection.verify!(path, handle, directory: directory)
|
|
129
|
-
unless snapshot(stat) == snapshot(handle.stat) && snapshot(File.lstat(path)) == snapshot(handle.stat)
|
|
193
|
+
unless snapshot(stat, directory: directory) == snapshot(handle.stat, directory: directory) && snapshot(File.lstat(path), directory: directory) == snapshot(handle.stat, directory: directory)
|
|
130
194
|
raise RuntimeUnavailableError, "network artifact changed while opening"
|
|
131
195
|
end
|
|
132
|
-
@handles[path] = {handle: handle, directory: directory, snapshot: snapshot(handle.stat)}
|
|
196
|
+
@handles[path] = {handle: handle, directory: directory, snapshot: snapshot(handle.stat, directory: directory)}
|
|
133
197
|
handle
|
|
134
198
|
rescue Exception
|
|
135
199
|
handle.close
|
|
@@ -137,8 +201,9 @@ module Ace
|
|
|
137
201
|
end
|
|
138
202
|
end
|
|
139
203
|
|
|
140
|
-
def snapshot(stat)
|
|
141
|
-
[stat.dev, stat.ino, stat.uid, stat.gid, stat.mode
|
|
204
|
+
def snapshot(stat, directory:)
|
|
205
|
+
identity = [stat.dev, stat.ino, stat.uid, stat.gid, stat.mode]
|
|
206
|
+
directory ? identity : identity + [stat.size, stat.mtime, stat.ctime]
|
|
142
207
|
end
|
|
143
208
|
end
|
|
144
209
|
end
|
|
@@ -7,6 +7,22 @@ module Ace
|
|
|
7
7
|
module Molecules
|
|
8
8
|
module ProtectedSocket
|
|
9
9
|
LIMIT = 65_536
|
|
10
|
+
# Fixed protocols that forbid descriptors use this for every control,
|
|
11
|
+
# binary-body and EOF read; rights never bypass a later framing check.
|
|
12
|
+
class Ingress
|
|
13
|
+
def initialize(socket) = @socket = socket
|
|
14
|
+
def to_io = @socket
|
|
15
|
+
|
|
16
|
+
def read_nonblock(length, exception: false)
|
|
17
|
+
result = @socket.recvmsg_nonblock(length, 0, 4096, scm_rights: true, exception: exception)
|
|
18
|
+
return result if result == :wait_readable
|
|
19
|
+
return nil if result.nil?
|
|
20
|
+
bytes, _, flags, *controls = result
|
|
21
|
+
controls.each { |control| control.unix_rights&.each(&:close) if control.cmsg_is?(Socket::SOL_SOCKET, Socket::SCM_RIGHTS) }
|
|
22
|
+
raise SecurityError, "protected ancillary input is forbidden" unless controls.empty? && (flags & Socket::MSG_CTRUNC).zero?
|
|
23
|
+
bytes.empty? ? nil : bytes
|
|
24
|
+
end
|
|
25
|
+
end
|
|
10
26
|
module_function
|
|
11
27
|
|
|
12
28
|
def root_path!(path, directory: false, owner: 0)
|
|
@@ -48,9 +64,12 @@ module Ace
|
|
|
48
64
|
socket&.close unless socket&.closed?
|
|
49
65
|
end
|
|
50
66
|
|
|
51
|
-
def socket_identity(path)
|
|
67
|
+
def socket_identity(path, mode: nil)
|
|
52
68
|
stat = File.lstat(path)
|
|
53
69
|
raise RuntimeUnavailableError, "protected endpoint is not a socket" unless stat.socket? && !stat.symlink?
|
|
70
|
+
if !mode.nil? && (!mode.is_a?(Integer) || !mode.between?(0, 0o7777) || (stat.mode & 0o7777) != mode)
|
|
71
|
+
raise RuntimeUnavailableError, "protected endpoint mode differs"
|
|
72
|
+
end
|
|
54
73
|
[stat.dev, stat.ino, stat.uid]
|
|
55
74
|
rescue SystemCallError
|
|
56
75
|
raise RuntimeUnavailableError, "protected endpoint is unavailable"
|
|
@@ -69,7 +88,10 @@ module Ace
|
|
|
69
88
|
raise RuntimeUnavailableError, "protected socket frame is oversized" if buffer.bytesize > limit
|
|
70
89
|
break if chunk == "\n"
|
|
71
90
|
end
|
|
72
|
-
|
|
91
|
+
buffer.force_encoding(Encoding::UTF_8)
|
|
92
|
+
raise RuntimeUnavailableError, "protected socket frame is malformed" unless buffer.valid_encoding?
|
|
93
|
+
value = JSON.parse(buffer, create_additions: false, max_nesting: 32,
|
|
94
|
+
allow_duplicate_key: false, allow_comments: false)
|
|
73
95
|
with_size ? {data: value, bytesize: buffer.bytesize} : value
|
|
74
96
|
rescue JSON::ParserError
|
|
75
97
|
raise RuntimeUnavailableError, "protected socket frame is malformed"
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "json"
|
|
3
|
+
require_relative "protected_artifact_set"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Runtime
|
|
7
|
+
module Molecules
|
|
8
|
+
# Ref-only installed discovery. Domain publication owns the projection;
|
|
9
|
+
# the selected child remains the original scope/admission authority.
|
|
10
|
+
class ProtectedTaskContextEntry
|
|
11
|
+
PATH = "/etc/ace/task-context-entry.json"
|
|
12
|
+
PRESENCE_PATHS = %w[/etc/ace/assignment-authorities.json /etc/ace/assignment-deployment-history.json /usr/local/lib/lab/herdr.installation.json].freeze
|
|
13
|
+
SCHEMA = "ace.protected-task-context-entry-selection/v1"
|
|
14
|
+
MANIFEST_SCHEMA = "lab.protected-command-entry/v1"
|
|
15
|
+
ROLE = "ace-assign-task-context"
|
|
16
|
+
METADATA_LIMIT = 65_536
|
|
17
|
+
FILE_LIMIT = 33_554_432
|
|
18
|
+
TOTAL_LIMIT = 268_435_456
|
|
19
|
+
COUNT_LIMIT = 4_096
|
|
20
|
+
Entry = Struct.new(:pin, :manifest, :body, :interpreter, keyword_init: true)
|
|
21
|
+
|
|
22
|
+
def initialize(artifacts_factory: -> { ProtectedArtifactSet.new(file_limit: FILE_LIMIT, total_limit: TOTAL_LIMIT, count_limit: COUNT_LIMIT) },
|
|
23
|
+
stat: File.method(:lstat))
|
|
24
|
+
@factory, @stat = artifacts_factory, stat
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Pure shape validation, never publication/original admission authority.
|
|
28
|
+
def validate_pin!(pin)
|
|
29
|
+
pin!(pin)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def with
|
|
33
|
+
unless present?(PATH)
|
|
34
|
+
refuse!("installed discovery is absent") if PRESENCE_PATHS.any? { |path| present?(path) }
|
|
35
|
+
return yield nil
|
|
36
|
+
end
|
|
37
|
+
@factory.call.with do |held|
|
|
38
|
+
@held = held
|
|
39
|
+
bytes, = held.read_path!(PATH, limit: METADATA_LIMIT)
|
|
40
|
+
projection = json!(bytes)
|
|
41
|
+
object!(projection, %w[schema task_context_entry])
|
|
42
|
+
refuse!("discovery schema") unless projection.fetch("schema") == SCHEMA
|
|
43
|
+
pin = pin!(projection.fetch("task_context_entry"))
|
|
44
|
+
held.verify_unchanged!
|
|
45
|
+
result = yield pin
|
|
46
|
+
held.verify_unchanged!
|
|
47
|
+
result
|
|
48
|
+
ensure
|
|
49
|
+
@held = nil
|
|
50
|
+
end
|
|
51
|
+
rescue KeyError, TypeError, NoMethodError, ArgumentError, IOError, SystemCallError, EncodingError
|
|
52
|
+
refuse!("installed discovery is unavailable")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Live descriptor lifetime is this callback only. Exact protected bytes
|
|
56
|
+
# go to stdin; callers never execute/reopen a manifest-selected pathname.
|
|
57
|
+
def with_entry(pin)
|
|
58
|
+
refuse!("entry requires held discovery") unless @held
|
|
59
|
+
pin = pin!(pin)
|
|
60
|
+
manifest = json!(@held.read!(pin.fetch("manifest")))
|
|
61
|
+
object!(manifest, %w[schema role wrapper interpreter bootstrap])
|
|
62
|
+
unless manifest.fetch("schema") == MANIFEST_SCHEMA && manifest.fetch("role") == ROLE && manifest.fetch("wrapper") == pin.fetch("wrapper")
|
|
63
|
+
refuse!("selected fixed role differs")
|
|
64
|
+
end
|
|
65
|
+
reference!(manifest.fetch("interpreter"))
|
|
66
|
+
bootstrap = manifest.fetch("bootstrap")
|
|
67
|
+
object!(bootstrap, %w[original_source owner preparation startup_entries])
|
|
68
|
+
%w[original_source owner preparation].each do |key|
|
|
69
|
+
reference!(bootstrap.fetch(key))
|
|
70
|
+
@held.read!(bootstrap.fetch(key))
|
|
71
|
+
end
|
|
72
|
+
entries = bootstrap.fetch("startup_entries")
|
|
73
|
+
refuse!("bootstrap entry references") unless entries.is_a?(Hash)
|
|
74
|
+
reference!(entries.fetch("ace-assign"))
|
|
75
|
+
read_references!(entries)
|
|
76
|
+
body = @held.read!(pin.fetch("wrapper"))
|
|
77
|
+
text = body.dup.force_encoding(Encoding::UTF_8)
|
|
78
|
+
refuse!("wrapper source encoding") unless text.valid_encoding? && !text.include?("\0")
|
|
79
|
+
@held.with_readonly_handle!(manifest.fetch("interpreter")) do |interpreter|
|
|
80
|
+
stat = interpreter.stat
|
|
81
|
+
refuse!("selected interpreter is not executable") unless stat.file? && stat.executable? && (stat.mode & 0o6000).zero?
|
|
82
|
+
@held.verify_unchanged!
|
|
83
|
+
result = yield Entry.new(pin: pin, manifest: immutable(manifest), body: body, interpreter: interpreter).freeze
|
|
84
|
+
@held.verify_unchanged!
|
|
85
|
+
result
|
|
86
|
+
end
|
|
87
|
+
rescue KeyError, TypeError, NoMethodError, ArgumentError, IOError, SystemCallError, EncodingError
|
|
88
|
+
refuse!("selected protected entry is unavailable")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def present?(path)
|
|
94
|
+
@stat.call(path)
|
|
95
|
+
true
|
|
96
|
+
rescue Errno::ENOENT
|
|
97
|
+
false
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def pin!(pin)
|
|
101
|
+
object!(pin, %w[manifest wrapper])
|
|
102
|
+
reference!(pin.fetch("manifest"), limit: METADATA_LIMIT)
|
|
103
|
+
reference!(pin.fetch("wrapper"))
|
|
104
|
+
immutable(pin)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def reference!(reference, limit: FILE_LIMIT)
|
|
108
|
+
object!(reference, %w[bytes path sha256])
|
|
109
|
+
path = reference.fetch("path")
|
|
110
|
+
unless path.is_a?(String) && path.encoding == Encoding::UTF_8 && path.valid_encoding? &&
|
|
111
|
+
path.bytesize.between?(2, 4096) && path.start_with?("/") && !path.include?("\0") && File.expand_path(path) == path &&
|
|
112
|
+
reference.fetch("bytes").is_a?(Integer) && reference.fetch("bytes").between?(1, limit) &&
|
|
113
|
+
reference.fetch("sha256").is_a?(String) && reference.fetch("sha256").match?(/\A[0-9a-f]{64}\z/)
|
|
114
|
+
refuse!("protected entry reference")
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def read_references!(root)
|
|
119
|
+
pending = [root]
|
|
120
|
+
until pending.empty?
|
|
121
|
+
value = pending.pop
|
|
122
|
+
if value.is_a?(Hash) && value.keys.sort == %w[bytes path sha256]
|
|
123
|
+
reference!(value)
|
|
124
|
+
@held.read!(value)
|
|
125
|
+
elsif value.is_a?(Hash) && value.keys.all? { |key| key.is_a?(String) }
|
|
126
|
+
pending.concat(value.values)
|
|
127
|
+
elsif value.is_a?(Array)
|
|
128
|
+
pending.concat(value)
|
|
129
|
+
else
|
|
130
|
+
refuse!("bootstrap reference graph")
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def json!(bytes)
|
|
136
|
+
text = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
137
|
+
refuse!("entry metadata encoding or bound") unless text.valid_encoding? && text.bytesize.between?(1, METADATA_LIMIT)
|
|
138
|
+
JSON.parse(text, create_additions: false, max_nesting: 32, allow_duplicate_key: false, allow_comments: false)
|
|
139
|
+
rescue JSON::ParserError
|
|
140
|
+
refuse!("entry metadata JSON")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def object!(value, fields)
|
|
144
|
+
refuse!("entry fields") unless value.is_a?(Hash) && value.keys.sort == fields.sort
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def immutable(value)
|
|
148
|
+
case value
|
|
149
|
+
when Hash then value.to_h { |key, item| [key.dup.freeze, immutable(item)] }.freeze
|
|
150
|
+
when Array then value.map { |item| immutable(item) }.freeze
|
|
151
|
+
when String then value.dup.freeze
|
|
152
|
+
else value.freeze
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def refuse!(detail)
|
|
157
|
+
raise RuntimeUnavailableError, "protected task-context entry unavailable: #{detail}"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Runtime
|
|
5
|
+
module Molecules
|
|
6
|
+
# Structural admission of source-owned pins. Artifact authentication belongs
|
|
7
|
+
# to the selected entry owner; these references are never an execution grant.
|
|
8
|
+
module ProtectedWorkerEntry
|
|
9
|
+
LIMITS = {"interpreter" => 33_554_432, "wrapper" => 1_048_576}.freeze
|
|
10
|
+
|
|
11
|
+
def self.validate!(value)
|
|
12
|
+
unless value.is_a?(Hash) && value.keys.sort == LIMITS.keys.sort
|
|
13
|
+
raise ArgumentError, "worker entry fields differ"
|
|
14
|
+
end
|
|
15
|
+
LIMITS.each do |key, limit|
|
|
16
|
+
reference = value[key]
|
|
17
|
+
unless reference.is_a?(Hash) && reference.keys.sort == %w[bytes path sha256] &&
|
|
18
|
+
reference["bytes"].is_a?(Integer) && reference["bytes"].between?(1, limit) &&
|
|
19
|
+
reference["sha256"].is_a?(String) && reference["sha256"].match?(/\A[0-9a-f]{64}\z/) &&
|
|
20
|
+
reference["path"].is_a?(String) && reference["path"].encoding == Encoding::UTF_8 &&
|
|
21
|
+
reference["path"].valid_encoding? && reference["path"].bytesize.between?(2, 4096) &&
|
|
22
|
+
!reference["path"].include?("\0") && reference["path"].start_with?("/") &&
|
|
23
|
+
File.expand_path(reference["path"]) == reference["path"]
|
|
24
|
+
raise ArgumentError, "worker entry reference differs"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
value
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "digest"
|
|
5
|
+
require_relative "protected_artifact_set"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Runtime
|
|
9
|
+
module Molecules
|
|
10
|
+
# Immutable local installer input. This contains selections only; live
|
|
11
|
+
# process and resource observations must come from their owning readers.
|
|
12
|
+
class ReadinessConfiguration
|
|
13
|
+
LIMIT = 65_536
|
|
14
|
+
SCHEMA = "ace.assign.readiness/v1"
|
|
15
|
+
FIELDS = %w[authority boundary_manifest mapping_id native project_id runtime schema slot_id worker].freeze
|
|
16
|
+
attr_reader :data
|
|
17
|
+
|
|
18
|
+
def self.path(slot)
|
|
19
|
+
unless slot.is_a?(String) && slot.match?(/\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/)
|
|
20
|
+
raise RuntimeUnavailableError, "invalid readiness slot"
|
|
21
|
+
end
|
|
22
|
+
"/etc/ace/execution-slots/#{slot}/readiness.json"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.decode(bytes, slot:)
|
|
26
|
+
text = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
27
|
+
unless text.bytesize.between?(1, LIMIT) && text.valid_encoding?
|
|
28
|
+
raise RuntimeUnavailableError, "readiness configuration is not bounded UTF-8"
|
|
29
|
+
end
|
|
30
|
+
new(JSON.parse(text, create_additions: false, max_nesting: 8,
|
|
31
|
+
allow_duplicate_key: false, allow_comments: false), slot: slot)
|
|
32
|
+
rescue JSON::ParserError, EncodingError
|
|
33
|
+
raise RuntimeUnavailableError, "readiness configuration is not strict JSON"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.load(slot:)
|
|
37
|
+
ProtectedArtifactSet.new.with do |artifacts|
|
|
38
|
+
bytes, = artifacts.read_path!(path(slot), limit: LIMIT)
|
|
39
|
+
value = decode(bytes, slot: slot)
|
|
40
|
+
artifacts.verify_unchanged!
|
|
41
|
+
value
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def initialize(data, slot:)
|
|
46
|
+
object!(data, FIELDS)
|
|
47
|
+
unless data["schema"] == SCHEMA && data["slot_id"] == slot
|
|
48
|
+
refuse!("readiness configuration selection differs")
|
|
49
|
+
end
|
|
50
|
+
%w[mapping_id project_id].each do |key|
|
|
51
|
+
refuse!("invalid readiness identifier") unless data[key].is_a?(String) &&
|
|
52
|
+
data[key].match?(/\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/)
|
|
53
|
+
end
|
|
54
|
+
object!(data.fetch("authority"), %w[gid groups socket_path uid])
|
|
55
|
+
object!(data.fetch("worker"), %w[gid groups uid])
|
|
56
|
+
%w[authority worker].each do |kind|
|
|
57
|
+
principal = data.fetch(kind)
|
|
58
|
+
unless %w[uid gid].all? { |key| principal[key].is_a?(Integer) && principal[key].positive? } &&
|
|
59
|
+
principal["groups"].is_a?(Array) && principal["groups"].all? { |gid| gid.is_a?(Integer) && gid.positive? } &&
|
|
60
|
+
principal["groups"].sort.uniq == principal["groups"]
|
|
61
|
+
refuse!("readiness principal differs")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
native = data.fetch("native")
|
|
65
|
+
object!(native, %w[executable executable_sha256 protocol socket_path version workspace_id])
|
|
66
|
+
unless native["protocol"] == 22 && native["version"] == "0.9.3" &&
|
|
67
|
+
native["workspace_id"].is_a?(String) && native["workspace_id"].match?(/\Aw[1-9][0-9]{0,8}\z/)
|
|
68
|
+
refuse!("readiness native protocol differs")
|
|
69
|
+
end
|
|
70
|
+
digest!(native["executable_sha256"])
|
|
71
|
+
boundary = data.fetch("boundary_manifest")
|
|
72
|
+
object!(boundary, %w[path sha256])
|
|
73
|
+
unless boundary["path"] == "/etc/ace/execution-slots/#{slot}/boundary-manifest.json"
|
|
74
|
+
refuse!("readiness boundary path differs")
|
|
75
|
+
end
|
|
76
|
+
digest!(boundary["sha256"])
|
|
77
|
+
[data.dig("authority", "socket_path"), native["socket_path"], native["executable"]].each { |path| path!(path) }
|
|
78
|
+
runtime = data.fetch("runtime")
|
|
79
|
+
object!(runtime, %w[dependencies interpreter_path load_paths])
|
|
80
|
+
path!(runtime.fetch("interpreter_path"))
|
|
81
|
+
paths = runtime.fetch("load_paths")
|
|
82
|
+
unless paths.is_a?(Array) && paths.size.between?(1, 16) && paths.uniq == paths
|
|
83
|
+
refuse!("readiness load paths differ")
|
|
84
|
+
end
|
|
85
|
+
paths.each { |path| path!(path) }
|
|
86
|
+
dependencies = runtime.fetch("dependencies")
|
|
87
|
+
unless dependencies.is_a?(Array) && dependencies.size.between?(1, 256) &&
|
|
88
|
+
dependencies.map { |ref| ref.fetch("path") }.uniq.size == dependencies.size
|
|
89
|
+
refuse!("readiness dependency closure differs")
|
|
90
|
+
end
|
|
91
|
+
dependencies.each do |ref|
|
|
92
|
+
object!(ref, %w[bytes path sha256])
|
|
93
|
+
path!(ref.fetch("path"))
|
|
94
|
+
digest!(ref.fetch("sha256"))
|
|
95
|
+
unless ref["bytes"].is_a?(Integer) && ref["bytes"].between?(1, ProtectedArtifactSet::LIMIT)
|
|
96
|
+
refuse!("readiness dependency size differs")
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
@data = immutable(data)
|
|
100
|
+
freeze
|
|
101
|
+
rescue KeyError, TypeError, ArgumentError
|
|
102
|
+
refuse!("readiness configuration fields differ")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def verify_selection!(mapping_id:, mapping:, authority:, installation:)
|
|
106
|
+
expected = {"schema" => SCHEMA, "slot_id" => mapping.fetch("execution_scope").fetch("slot_id"),
|
|
107
|
+
"mapping_id" => mapping_id, "project_id" => mapping.fetch("project_id"),
|
|
108
|
+
"authority" => authority.slice("socket_path", "uid", "gid", "groups"),
|
|
109
|
+
"worker" => {"uid" => mapping.fetch("worker_uid"), "gid" => mapping.fetch("worker_gid"),
|
|
110
|
+
"groups" => mapping.fetch("worker_groups")}, "native" => mapping.fetch("native"),
|
|
111
|
+
"boundary_manifest" => {"path" => "/etc/ace/execution-slots/#{data.fetch('slot_id')}/boundary-manifest.json",
|
|
112
|
+
"sha256" => mapping.fetch("execution_scope").fetch("boundary_manifest_sha256")}, "runtime" => data.fetch("runtime")}
|
|
113
|
+
refuse!("readiness configuration does not join protected deployment") unless data == expected
|
|
114
|
+
artifacts = installation.fetch("artifacts")
|
|
115
|
+
closure = data.fetch("runtime").fetch("dependencies")
|
|
116
|
+
unless closure.all? { |ref| artifacts.any? { |artifact|
|
|
117
|
+
%w[runtime_dependency readiness_executable].include?(artifact.fetch("role")) &&
|
|
118
|
+
artifact.fetch("view_path") == ref.fetch("path") && artifact.fetch("sha256") == ref.fetch("sha256") } } &&
|
|
119
|
+
artifacts.any? { |artifact| artifact.fetch("role") == "runtime_dependency" &&
|
|
120
|
+
artifact.fetch("view_path") == data.dig("runtime", "interpreter_path") }
|
|
121
|
+
refuse!("readiness dependency closure does not join installation")
|
|
122
|
+
end
|
|
123
|
+
true
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def object!(value, keys)
|
|
129
|
+
refuse!("readiness object fields differ") unless value.is_a?(Hash) && value.keys.sort == keys.sort
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def path!(value)
|
|
133
|
+
refuse!("readiness path differs") unless value.is_a?(String) && value.bytesize.between?(2, 4096) &&
|
|
134
|
+
value.start_with?("/") && !value.include?("\0") && File.expand_path(value) == value
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def digest!(value)
|
|
138
|
+
refuse!("readiness digest differs") unless value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def immutable(value)
|
|
142
|
+
case value
|
|
143
|
+
when Hash then value.to_h { |key, item| [key.dup.freeze, immutable(item)] }.freeze
|
|
144
|
+
when Array then value.map { |item| immutable(item) }.freeze
|
|
145
|
+
when String then value.dup.freeze
|
|
146
|
+
else value
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def refuse!(message) = raise(RuntimeUnavailableError, message)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|