ace-runtime 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +58 -0
- data/README.md +9 -0
- data/docs/usage.md +123 -0
- data/lib/ace/runtime/molecules/cgroup_observation.rb +148 -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 +663 -0
- data/lib/ace/runtime/molecules/kernel_view_topology.rb +133 -0
- data/lib/ace/runtime/molecules/linux_mount_info.rb +101 -0
- data/lib/ace/runtime/molecules/network_installation_evidence.rb +332 -0
- data/lib/ace/runtime/molecules/process_identity.rb +123 -0
- data/lib/ace/runtime/molecules/protected_artifact_set.rb +211 -0
- data/lib/ace/runtime/molecules/protected_linux.rb +118 -0
- data/lib/ace/runtime/molecules/protected_socket.rb +119 -0
- 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 +411 -0
- data/lib/ace/runtime/testing/adapter_contract.rb +9 -0
- data/lib/ace/runtime/version.rb +1 -1
- data/lib/ace/runtime.rb +3 -0
- data/native/README.md +124 -0
- data/native/build-worker-gate +20 -0
- data/native/worker_gate.c +385 -0
- metadata +63 -2
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "socket"
|
|
5
|
+
require "fiddle"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Runtime
|
|
9
|
+
module Molecules
|
|
10
|
+
# A PID is an address, not an identity. Keep only non-secret OS facts;
|
|
11
|
+
# command lines may contain credentials and are never journaled.
|
|
12
|
+
class ProcessIdentity
|
|
13
|
+
def initialize(executor: Open3, hostname: Socket.gethostname, birth_reader: nil)
|
|
14
|
+
@executor = executor
|
|
15
|
+
@hostname = hostname
|
|
16
|
+
@birth_reader = birth_reader || method(:native_birth)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def owner(shell_pid:, caller_pid:)
|
|
20
|
+
shell = capture(shell_pid)
|
|
21
|
+
return nil unless shell && caller_pid.is_a?(Integer) && caller_pid != shell_pid
|
|
22
|
+
|
|
23
|
+
seen = []
|
|
24
|
+
observations = []
|
|
25
|
+
current = caller_pid
|
|
26
|
+
child = nil
|
|
27
|
+
64.times do
|
|
28
|
+
return nil if seen.include?(current)
|
|
29
|
+
seen << current
|
|
30
|
+
identity = capture(current)
|
|
31
|
+
return nil unless identity && identity["uid"] == shell["uid"]
|
|
32
|
+
parent = parent_pid(current)
|
|
33
|
+
return nil unless parent
|
|
34
|
+
observations << [identity, parent]
|
|
35
|
+
child = identity
|
|
36
|
+
if parent == shell_pid
|
|
37
|
+
return nil unless capture(shell_pid) == shell && observations.all? { |saved, ppid| capture(saved["pid"]) == saved && parent_pid(saved["pid"]) == ppid }
|
|
38
|
+
return {"shell_identity" => shell, "process_identity" => child}
|
|
39
|
+
end
|
|
40
|
+
current = parent
|
|
41
|
+
end
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def parent_pid(pid)
|
|
46
|
+
output, status = @executor.capture2("ps", "-p", pid.to_s, "-o", "ppid=", stdin_data: "")
|
|
47
|
+
value = output.strip
|
|
48
|
+
status.success? && value.match?(/\A[0-9]+\z/) ? value.to_i : nil
|
|
49
|
+
rescue SystemCallError, IOError
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def capture(pid)
|
|
54
|
+
return nil unless pid.is_a?(Integer) && pid.positive?
|
|
55
|
+
|
|
56
|
+
before = @birth_reader.call(pid)
|
|
57
|
+
return nil unless before
|
|
58
|
+
output, status = @executor.capture2("ps", "-p", pid.to_s, "-o", "pid=,uid=,stat=,lstart=", stdin_data: "")
|
|
59
|
+
return nil unless status.success?
|
|
60
|
+
|
|
61
|
+
match = output.strip.match(/\A(\d+)\s+(\d+)\s+(\S+)\s+(.+)\z/)
|
|
62
|
+
return nil unless match && match[1].to_i == pid && !match[3].match?(/[ZX]/)
|
|
63
|
+
|
|
64
|
+
birth = @birth_reader.call(pid)
|
|
65
|
+
return nil unless birth && birth == before
|
|
66
|
+
|
|
67
|
+
{"pid" => pid, "uid" => match[2].to_i, "started_at" => birth, "host" => @hostname}
|
|
68
|
+
rescue SystemCallError, IOError
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Linux start ticks are scoped to the boot; Darwin exposes microsecond
|
|
73
|
+
# process birth through libproc's documented proc_bsdinfo structure.
|
|
74
|
+
# Platforms lacking either exact facility remain unobservable.
|
|
75
|
+
def native_birth(pid)
|
|
76
|
+
if RUBY_PLATFORM.include?("linux")
|
|
77
|
+
stat = File.read("/proc/#{pid}/stat").b
|
|
78
|
+
# comm is parenthesized and may itself contain parentheses or
|
|
79
|
+
# newlines. The final closing parenthesis precedes numeric fields.
|
|
80
|
+
match = stat.match(/\A#{pid} \(.*\) ([^\n]+)\n?\z/m)
|
|
81
|
+
return nil unless match
|
|
82
|
+
fields = match[1].split
|
|
83
|
+
ticks = fields[19]
|
|
84
|
+
return nil unless fields[0]&.match?(/\A[A-Za-z]\z/) && ticks&.match?(/\A[0-9]+\z/)
|
|
85
|
+
boot = File.read("/proc/sys/kernel/random/boot_id").strip
|
|
86
|
+
return nil if boot.empty?
|
|
87
|
+
"linux:#{boot}:#{ticks}"
|
|
88
|
+
elsif RUBY_PLATFORM.include?("darwin")
|
|
89
|
+
library = Fiddle.dlopen("/usr/lib/libproc.dylib")
|
|
90
|
+
function = Fiddle::Function.new(library["proc_pidinfo"],
|
|
91
|
+
[Fiddle::TYPE_INT, Fiddle::TYPE_INT, Fiddle::TYPE_LONG_LONG, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT], Fiddle::TYPE_INT)
|
|
92
|
+
buffer = Fiddle::Pointer.malloc(136)
|
|
93
|
+
return nil unless function.call(pid, 3, 0, buffer, 136) == 136
|
|
94
|
+
seconds, micros = buffer[120, 16].unpack("Q2")
|
|
95
|
+
return nil unless seconds.positive? && micros < 1_000_000
|
|
96
|
+
"darwin:#{seconds}:#{micros}"
|
|
97
|
+
end
|
|
98
|
+
rescue SystemCallError, IOError, IndexError, Fiddle::DLError
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def observe(identity)
|
|
103
|
+
at = Time.now.utc.iso8601
|
|
104
|
+
unless identity.is_a?(Hash) && identity["host"] == @hostname &&
|
|
105
|
+
identity["started_at"].is_a?(String) && identity["uid"].is_a?(Integer)
|
|
106
|
+
return {"liveness" => "unknown", "observed_at" => at, "reason" => "verified process identity unavailable"}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
live = capture(identity["pid"])
|
|
110
|
+
if live == identity
|
|
111
|
+
{"liveness" => "live", "observed_at" => at, "reason" => "process identity matches", "identity" => live}
|
|
112
|
+
elsif live
|
|
113
|
+
{"liveness" => "unknown", "observed_at" => at, "reason" => "process address reused"}
|
|
114
|
+
else
|
|
115
|
+
# An inaccessible ps result cannot prove death or absence of
|
|
116
|
+
# descendants. Recovery retains the worktree and writer slot.
|
|
117
|
+
{"liveness" => "unknown", "observed_at" => at, "reason" => "process identity no longer observable"}
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require_relative "cgroup_observation"
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
require_relative "protected_socket"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Runtime
|
|
10
|
+
module Molecules
|
|
11
|
+
# Content evidence only. Retains every artifact and ancestor descriptor for
|
|
12
|
+
# one bounded verification, then rechecks their identities before closing.
|
|
13
|
+
class ProtectedArtifactSet
|
|
14
|
+
LIMIT = 1_048_576
|
|
15
|
+
TOTAL_LIMIT = 16 * LIMIT
|
|
16
|
+
COUNT_LIMIT = 256
|
|
17
|
+
|
|
18
|
+
class Protection
|
|
19
|
+
# These local Linux filesystems enforce POSIX mode/ACL mask semantics.
|
|
20
|
+
# Other models (including remote/FUSE filesystems) are not presumed safe.
|
|
21
|
+
FILESYSTEMS = %w[ext2 ext3 ext4 xfs btrfs tmpfs].freeze
|
|
22
|
+
|
|
23
|
+
def initialize(mounts: CgroupObservation::KernelFiles.new)
|
|
24
|
+
@mounts = mounts
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def root_path!(path)
|
|
28
|
+
ProtectedSocket.root_path!(path)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def verify!(path, handle, directory:)
|
|
32
|
+
stat = handle.stat
|
|
33
|
+
unless trusted_owner?(stat) && (stat.mode & 0o022).zero? &&
|
|
34
|
+
(directory ? stat.directory? : stat.file?)
|
|
35
|
+
raise RuntimeUnavailableError, "network artifact ownership, mode or kind is unsafe"
|
|
36
|
+
end
|
|
37
|
+
unless FILESYSTEMS.include?(@mounts.mount_identity(handle).fetch("filesystem_type"))
|
|
38
|
+
raise RuntimeUnavailableError, "network artifact filesystem protection is unsupported"
|
|
39
|
+
end
|
|
40
|
+
# On these filesystems group bits are the access ACL mask: with no
|
|
41
|
+
# group/other write, no named non-root user/group can write either.
|
|
42
|
+
# Default ACLs grant no access to the existing descriptor. Every
|
|
43
|
+
# referenced file and ancestor is checked, including inherited mode.
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def trusted_owner?(stat) = stat.uid.zero?
|
|
49
|
+
end
|
|
50
|
+
|
|
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
|
|
59
|
+
@protection = protection
|
|
60
|
+
@file_limit, @total_limit = file_limit, total_limit
|
|
61
|
+
@count_limit = count_limit
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def with
|
|
65
|
+
@handles, @references, @contents, @total = {}, {}, {}, 0
|
|
66
|
+
yield self
|
|
67
|
+
ensure
|
|
68
|
+
@handles&.each_value { |entry| entry.fetch(:handle).close }
|
|
69
|
+
@handles = @references = @contents = nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def read!(reference)
|
|
73
|
+
path = reference.fetch("path")
|
|
74
|
+
previous = @references[path]
|
|
75
|
+
if previous
|
|
76
|
+
raise RuntimeUnavailableError, "network artifact references conflict" unless previous == reference
|
|
77
|
+
return @contents.fetch(path)
|
|
78
|
+
end
|
|
79
|
+
raise RuntimeUnavailableError, "network artifact graph is oversized" if @references.size >= @count_limit
|
|
80
|
+
size = reference.fetch("bytes")
|
|
81
|
+
unless size.is_a?(Integer) && size.between?(1, @file_limit) && @total + size <= @total_limit
|
|
82
|
+
raise RuntimeUnavailableError, "network artifact bytes exceed bounds"
|
|
83
|
+
end
|
|
84
|
+
@protection.root_path!(path)
|
|
85
|
+
ancestors(path).reverse_each { |ancestor| pin!(ancestor, directory: true) }
|
|
86
|
+
handle = pin!(path, directory: false)
|
|
87
|
+
unless handle.stat.size == size
|
|
88
|
+
raise RuntimeUnavailableError, "network artifact declared length differs"
|
|
89
|
+
end
|
|
90
|
+
bytes = handle.read(size + 1)
|
|
91
|
+
unless bytes.is_a?(String) && bytes.bytesize == size &&
|
|
92
|
+
Digest::SHA256.hexdigest(bytes) == reference.fetch("sha256")
|
|
93
|
+
raise RuntimeUnavailableError, "network artifact content differs"
|
|
94
|
+
end
|
|
95
|
+
@references[path] = reference.dup.freeze
|
|
96
|
+
@contents[path] = bytes.freeze
|
|
97
|
+
@total += size
|
|
98
|
+
bytes
|
|
99
|
+
end
|
|
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
|
+
|
|
156
|
+
def verify_unchanged!
|
|
157
|
+
@handles.each do |path, entry|
|
|
158
|
+
handle = entry.fetch(:handle)
|
|
159
|
+
unless snapshot(handle.stat, directory: entry.fetch(:directory)) == entry.fetch(:snapshot) &&
|
|
160
|
+
snapshot(File.lstat(path), directory: entry.fetch(:directory)) == entry.fetch(:snapshot)
|
|
161
|
+
raise RuntimeUnavailableError, "network artifact changed during verification"
|
|
162
|
+
end
|
|
163
|
+
@protection.verify!(path, handle, directory: entry.fetch(:directory))
|
|
164
|
+
end
|
|
165
|
+
true
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
def ancestors(path)
|
|
171
|
+
result = []
|
|
172
|
+
current = File.dirname(path)
|
|
173
|
+
loop do
|
|
174
|
+
result << current
|
|
175
|
+
break if current == "/"
|
|
176
|
+
current = File.dirname(current)
|
|
177
|
+
end
|
|
178
|
+
result
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def pin!(path, directory:)
|
|
182
|
+
return @handles.fetch(path).fetch(:handle) if @handles.key?(path)
|
|
183
|
+
stat = File.lstat(path)
|
|
184
|
+
unless !stat.symlink? && (directory ? stat.directory? : stat.file?)
|
|
185
|
+
raise RuntimeUnavailableError, "network artifact kind or link is unsafe"
|
|
186
|
+
end
|
|
187
|
+
# NONBLOCK also prevents blocking if a hostile special file replaces a
|
|
188
|
+
# regular file between lstat and open; descriptor type is checked below.
|
|
189
|
+
handle = File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
|
|
190
|
+
begin
|
|
191
|
+
handle.close_on_exec = true
|
|
192
|
+
@protection.verify!(path, handle, directory: directory)
|
|
193
|
+
unless snapshot(stat, directory: directory) == snapshot(handle.stat, directory: directory) && snapshot(File.lstat(path), directory: directory) == snapshot(handle.stat, directory: directory)
|
|
194
|
+
raise RuntimeUnavailableError, "network artifact changed while opening"
|
|
195
|
+
end
|
|
196
|
+
@handles[path] = {handle: handle, directory: directory, snapshot: snapshot(handle.stat, directory: directory)}
|
|
197
|
+
handle
|
|
198
|
+
rescue Exception
|
|
199
|
+
handle.close
|
|
200
|
+
raise
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
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]
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "json"
|
|
3
|
+
require "socket"
|
|
4
|
+
require "fiddle"
|
|
5
|
+
require_relative "process_identity"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Runtime
|
|
9
|
+
module Molecules
|
|
10
|
+
# Kernel observations for protected launch. No absence inferred from ps.
|
|
11
|
+
class ProtectedLinux
|
|
12
|
+
def initialize(identity: ProcessIdentity.new)
|
|
13
|
+
@identity = identity
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def supported!
|
|
17
|
+
unless RUBY_PLATFORM.include?("linux") && File.read("/proc/sys/kernel/yama/ptrace_scope").strip == "2"
|
|
18
|
+
raise RuntimeUnavailableError, "protected launch requires Linux Yama ptrace_scope=2"
|
|
19
|
+
end
|
|
20
|
+
probe = pidfd_handle(Process.pid)
|
|
21
|
+
raise RuntimeUnavailableError, "pidfd self observation is invalid" if exited?(probe)
|
|
22
|
+
true
|
|
23
|
+
rescue SystemCallError, IOError
|
|
24
|
+
raise RuntimeUnavailableError, "protected process policy is unavailable"
|
|
25
|
+
ensure
|
|
26
|
+
probe&.close
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def capture(pid)
|
|
30
|
+
before = @identity.capture(pid)
|
|
31
|
+
raise RuntimeUnavailableError, "exact kernel process is unavailable" unless before
|
|
32
|
+
fields = File.readlines("/proc/#{pid}/status").to_h { |line| line.strip.split(/:\s*/, 2) }
|
|
33
|
+
ids = %w[Uid Gid].to_h { |name| [name, fields.fetch(name).split.map { |v| Integer(v, 10) }] }
|
|
34
|
+
caps = %w[CapInh CapPrm CapEff CapBnd CapAmb].to_h { |name| [name, Integer(fields.fetch(name), 16)] }
|
|
35
|
+
unless @identity.capture(pid) == before && ids.fetch("Uid").first == before.fetch("uid") && ids.values.all? { |v| v.size == 4 && v.uniq.size == 1 } &&
|
|
36
|
+
caps.values.all?(&:zero?) && fields.fetch("NoNewPrivs") == "1"
|
|
37
|
+
raise RuntimeUnavailableError, "process identity or capability policy is unsafe"
|
|
38
|
+
end
|
|
39
|
+
before.merge("gid" => ids.fetch("Gid").first,
|
|
40
|
+
"groups" => fields.fetch("Groups", "").split.map { |v| Integer(v, 10) }.sort,
|
|
41
|
+
"parent_pid" => Integer(fields.fetch("PPid"), 10))
|
|
42
|
+
rescue SystemCallError, KeyError, ArgumentError
|
|
43
|
+
raise RuntimeUnavailableError, "kernel process policy is unreadable"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def peer(socket)
|
|
47
|
+
raise RuntimeUnavailableError, "protected socket peers require Linux" unless RUBY_PLATFORM.include?("linux")
|
|
48
|
+
pid, uid, gid = socket.getsockopt(Socket::SOL_SOCKET, Socket::SO_PEERCRED).data.unpack("i!3")
|
|
49
|
+
value = capture(pid)
|
|
50
|
+
unless value["uid"] == uid && value["gid"] == gid
|
|
51
|
+
raise RuntimeUnavailableError, "socket peer changed kernel identity"
|
|
52
|
+
end
|
|
53
|
+
value
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def pin(identity)
|
|
57
|
+
handle = pidfd_handle(identity.fetch("pid"))
|
|
58
|
+
begin
|
|
59
|
+
unless same?(identity, capture(identity.fetch("pid")))
|
|
60
|
+
raise RuntimeUnavailableError, "process changed while acquiring pidfd"
|
|
61
|
+
end
|
|
62
|
+
handle
|
|
63
|
+
rescue Exception
|
|
64
|
+
handle.close
|
|
65
|
+
raise
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def same?(left, right)
|
|
70
|
+
%w[pid uid gid started_at host groups parent_pid].all? { |key| left[key] == right[key] }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Socket peer ancestry, with every PID incarnation rechecked after the
|
|
74
|
+
# walk. Same UID alone never attributes a sibling to the native child.
|
|
75
|
+
def descendant?(identity, ancestor)
|
|
76
|
+
observations = []
|
|
77
|
+
current = identity.fetch("pid")
|
|
78
|
+
64.times do
|
|
79
|
+
return false if observations.any? { |item| item.fetch("pid") == current }
|
|
80
|
+
observed = capture(current)
|
|
81
|
+
return false unless observed["uid"] == ancestor["uid"]
|
|
82
|
+
return false if observations.empty? && !same?(identity, observed)
|
|
83
|
+
observations << observed
|
|
84
|
+
if current == ancestor.fetch("pid")
|
|
85
|
+
return same?(ancestor, observed) && observations.all? { |saved| same?(saved, capture(saved.fetch("pid"))) }
|
|
86
|
+
end
|
|
87
|
+
current = observed.fetch("parent_pid")
|
|
88
|
+
return false unless current.is_a?(Integer) && current.positive?
|
|
89
|
+
end
|
|
90
|
+
false
|
|
91
|
+
rescue RuntimeUnavailableError, KeyError
|
|
92
|
+
false
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def live!(identity)
|
|
96
|
+
raise RuntimeUnavailableError, "exact process identity changed" unless same?(identity, capture(identity.fetch("pid")))
|
|
97
|
+
true
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def exited?(handle, timeout: 0)
|
|
101
|
+
!IO.select([handle], nil, nil, timeout).nil?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def pidfd_handle(pid)
|
|
107
|
+
library = Fiddle.dlopen(nil)
|
|
108
|
+
call = Fiddle::Function.new(library["pidfd_open"], [Fiddle::TYPE_INT, Fiddle::TYPE_INT], Fiddle::TYPE_INT)
|
|
109
|
+
fd = call.call(pid, 0)
|
|
110
|
+
raise RuntimeUnavailableError, "exact pidfd is unavailable" if fd.negative?
|
|
111
|
+
IO.for_fd(fd, autoclose: true)
|
|
112
|
+
rescue Fiddle::DLError
|
|
113
|
+
raise RuntimeUnavailableError, "exact pidfd capability is unsupported"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "socket"
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Runtime
|
|
7
|
+
module Molecules
|
|
8
|
+
module ProtectedSocket
|
|
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
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
def root_path!(path, directory: false, owner: 0)
|
|
29
|
+
unless path.is_a?(String) && path.start_with?("/") && File.expand_path(path) == path
|
|
30
|
+
raise RuntimeUnavailableError, "protected path must be canonical and absolute"
|
|
31
|
+
end
|
|
32
|
+
current = path
|
|
33
|
+
loop do
|
|
34
|
+
stat = File.lstat(current)
|
|
35
|
+
unless !stat.symlink? && (current == path ? stat.uid == owner : [0, owner].include?(stat.uid)) && (stat.mode & 0o022).zero?
|
|
36
|
+
raise RuntimeUnavailableError, "protected path ownership or ancestry is unsafe"
|
|
37
|
+
end
|
|
38
|
+
if current != path && !stat.directory?
|
|
39
|
+
raise RuntimeUnavailableError, "protected ancestor is not a directory"
|
|
40
|
+
end
|
|
41
|
+
if current == path && directory && !stat.directory?
|
|
42
|
+
raise RuntimeUnavailableError, "protected root is not a directory"
|
|
43
|
+
end
|
|
44
|
+
break if current == "/"
|
|
45
|
+
current = File.dirname(current)
|
|
46
|
+
end
|
|
47
|
+
true
|
|
48
|
+
rescue SystemCallError
|
|
49
|
+
raise RuntimeUnavailableError, "protected path is unavailable"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def connect(path, deadline: self.deadline)
|
|
53
|
+
socket = Socket.new(Socket::AF_UNIX, Socket::SOCK_STREAM, 0)
|
|
54
|
+
socket.close_on_exec = true
|
|
55
|
+
result = socket.connect_nonblock(Socket.sockaddr_un(path), exception: false)
|
|
56
|
+
if result == :wait_writable
|
|
57
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
58
|
+
unless remaining.positive? && IO.select(nil, [socket], nil, remaining) && socket.getsockopt(Socket::SOL_SOCKET, Socket::SO_ERROR).int.zero?
|
|
59
|
+
raise RuntimeUnavailableError, "protected socket connection deadline expired"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
yield socket
|
|
63
|
+
ensure
|
|
64
|
+
socket&.close unless socket&.closed?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def socket_identity(path, mode: nil)
|
|
68
|
+
stat = File.lstat(path)
|
|
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
|
|
73
|
+
[stat.dev, stat.ino, stat.uid]
|
|
74
|
+
rescue SystemCallError
|
|
75
|
+
raise RuntimeUnavailableError, "protected endpoint is unavailable"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def read(socket, deadline:, limit: LIMIT, with_size: false)
|
|
79
|
+
buffer = +""
|
|
80
|
+
loop do
|
|
81
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
82
|
+
raise RuntimeUnavailableError, "protected socket deadline expired" unless remaining.positive? &&
|
|
83
|
+
IO.select([socket], nil, nil, remaining)
|
|
84
|
+
chunk = socket.read_nonblock(1, exception: false)
|
|
85
|
+
next if chunk == :wait_readable
|
|
86
|
+
raise RuntimeUnavailableError, "protected socket closed before response" unless chunk
|
|
87
|
+
buffer << chunk
|
|
88
|
+
raise RuntimeUnavailableError, "protected socket frame is oversized" if buffer.bytesize > limit
|
|
89
|
+
break if chunk == "\n"
|
|
90
|
+
end
|
|
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)
|
|
95
|
+
with_size ? {data: value, bytesize: buffer.bytesize} : value
|
|
96
|
+
rescue JSON::ParserError
|
|
97
|
+
raise RuntimeUnavailableError, "protected socket frame is malformed"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def write(socket, value, deadline:, limit: LIMIT)
|
|
101
|
+
bytes = JSON.generate(value) + "\n"
|
|
102
|
+
raise RuntimeUnavailableError, "protected socket frame is oversized" if bytes.bytesize > limit
|
|
103
|
+
until bytes.empty?
|
|
104
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
105
|
+
raise RuntimeUnavailableError, "protected socket deadline expired" unless remaining.positive? &&
|
|
106
|
+
IO.select(nil, [socket], nil, remaining)
|
|
107
|
+
sent = socket.write_nonblock(bytes, exception: false)
|
|
108
|
+
next if sent == :wait_writable
|
|
109
|
+
bytes = bytes.byteslice(sent..)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def deadline(seconds = 5)
|
|
114
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|