ace-runtime 0.2.0 → 0.4.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 +46 -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/owned_child_scope.rb +81 -0
- data/lib/ace/runtime/molecules/process_identity.rb +28 -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 +12 -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
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fiddle"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Runtime
|
|
7
|
+
module Molecules
|
|
8
|
+
# One ephemeral single-thread helper owns every child in this scope.
|
|
9
|
+
# Linux reparents detached orphans to this subreaper, not container PID1.
|
|
10
|
+
# Unreaped direct children pin their PIDs until this owner reaps them.
|
|
11
|
+
class OwnedChildScope
|
|
12
|
+
class CleanupUncertain < StandardError; end
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
unless RUBY_PLATFORM.include?("linux") && Thread.list.count(&:alive?) == 1
|
|
16
|
+
raise CleanupUncertain, "child scope requires a sole Linux helper thread"
|
|
17
|
+
end
|
|
18
|
+
@owner = Process.pid
|
|
19
|
+
raise CleanupUncertain, "child scope has preexisting children" unless children.empty?
|
|
20
|
+
# An inherited ignore/custom reaper could discard a child's wait
|
|
21
|
+
# status and unpin its PID. This sole ephemeral helper owns the
|
|
22
|
+
# disposition; establish it while the verified child set is empty.
|
|
23
|
+
Signal.trap("CHLD", "DEFAULT")
|
|
24
|
+
prctl = Fiddle::Function.new(Fiddle::Handle::DEFAULT["prctl"],
|
|
25
|
+
[Fiddle::TYPE_INT, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG], Fiddle::TYPE_INT)
|
|
26
|
+
raise CleanupUncertain, "cannot establish child subreaper" unless prctl.call(36, 1, 0, 0, 0).zero?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Preserve the leader's wait status for the executor. Kill/reap only
|
|
30
|
+
# kernel-owned direct children; adoption closes setsid/double-fork escape.
|
|
31
|
+
def cleanup!(leader:, timeout: 5)
|
|
32
|
+
unless Process.pid == @owner && Thread.list.count(&:alive?) == 1 &&
|
|
33
|
+
timeout.is_a?(Numeric) && timeout.finite? && timeout.positive? && timeout <= 5
|
|
34
|
+
raise CleanupUncertain, "child scope ownership is unavailable"
|
|
35
|
+
end
|
|
36
|
+
deadline = monotonic + timeout
|
|
37
|
+
owned = children
|
|
38
|
+
raise CleanupUncertain, "original child is no longer owned" unless owned.include?(leader)
|
|
39
|
+
signal("TERM", -leader)
|
|
40
|
+
loop do
|
|
41
|
+
owned = children
|
|
42
|
+
raise CleanupUncertain, "original child was reaped outside its scope" unless owned.include?(leader)
|
|
43
|
+
owned.each do |pid|
|
|
44
|
+
signal("KILL", pid)
|
|
45
|
+
Process.waitpid(pid, Process::WNOHANG) unless pid == leader
|
|
46
|
+
end
|
|
47
|
+
remaining = children
|
|
48
|
+
return if remaining == [leader] && zombie?(leader)
|
|
49
|
+
raise CleanupUncertain, "owned child cleanup deadline elapsed" if monotonic >= deadline
|
|
50
|
+
IO.select(nil, nil, nil, 0.01)
|
|
51
|
+
end
|
|
52
|
+
rescue Errno::ECHILD, Errno::ENOENT, IOError
|
|
53
|
+
raise CleanupUncertain, "owned child cleanup cannot be confirmed"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def children
|
|
59
|
+
File.open("/proc/self/task/#{@owner}/children", "r") do |file|
|
|
60
|
+
bytes = file.read(65_537) || ""
|
|
61
|
+
raise CleanupUncertain, "owned child inventory exceeds bound" if bytes.bytesize > 65_536
|
|
62
|
+
values = bytes.split.map { |value| Integer(value, 10) }
|
|
63
|
+
unless values.uniq == values && values.all?(&:positive?)
|
|
64
|
+
raise CleanupUncertain, "owned child inventory is invalid"
|
|
65
|
+
end
|
|
66
|
+
values.sort
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def signal(name, pid)
|
|
71
|
+
Process.kill(name, pid)
|
|
72
|
+
rescue Errno::ESRCH
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def zombie?(pid) = File.read("/proc/#{pid}/stat").rpartition(")").last.split.first == "Z"
|
|
77
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "open3"
|
|
4
4
|
require "socket"
|
|
5
5
|
require "fiddle"
|
|
6
|
+
require "time"
|
|
6
7
|
|
|
7
8
|
module Ace
|
|
8
9
|
module Runtime
|
|
@@ -117,6 +118,33 @@ module Ace
|
|
|
117
118
|
{"liveness" => "unknown", "observed_at" => at, "reason" => "process identity no longer observable"}
|
|
118
119
|
end
|
|
119
120
|
end
|
|
121
|
+
|
|
122
|
+
# Positive evidence that this exact historical generation ended. A
|
|
123
|
+
# failed capture alone says nothing: EPERM/inaccessible probes remain
|
|
124
|
+
# unknown. PID reuse is conclusive only when the exact birth differs.
|
|
125
|
+
def generation_status(identity, process: Process)
|
|
126
|
+
at = Time.now.utc.iso8601
|
|
127
|
+
unknown = {"liveness" => "unknown", "observed_at" => at}
|
|
128
|
+
unless identity.is_a?(Hash) && identity.keys.sort == %w[host pid started_at uid] &&
|
|
129
|
+
identity["host"] == @hostname && identity["pid"].is_a?(Integer) && identity["pid"].positive? &&
|
|
130
|
+
identity["uid"].is_a?(Integer) && identity["uid"] >= 0 &&
|
|
131
|
+
identity["started_at"].is_a?(String) && !identity["started_at"].empty?
|
|
132
|
+
return unknown.merge("reason" => "verified process generation unavailable")
|
|
133
|
+
end
|
|
134
|
+
current = capture(identity.fetch("pid"))
|
|
135
|
+
return {"liveness" => "live", "observed_at" => at, "identity" => current} if current == identity
|
|
136
|
+
if current && current.fetch("started_at") != identity.fetch("started_at")
|
|
137
|
+
return {"liveness" => "ended", "observed_at" => at, "reason" => "pid generation replaced", "identity" => current}
|
|
138
|
+
end
|
|
139
|
+
begin
|
|
140
|
+
process.kill(0, identity.fetch("pid"))
|
|
141
|
+
unknown.merge("reason" => "process address exists but generation is unobservable")
|
|
142
|
+
rescue Errno::ESRCH
|
|
143
|
+
{"liveness" => "ended", "observed_at" => at, "reason" => "kernel reports no process at address"}
|
|
144
|
+
rescue SystemCallError
|
|
145
|
+
unknown.merge("reason" => "kernel process presence is unavailable")
|
|
146
|
+
end
|
|
147
|
+
end
|
|
120
148
|
end
|
|
121
149
|
end
|
|
122
150
|
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
|