ace-lab 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "ace/runtime/molecules/protected_socket"
5
+ require "ace/herdr/molecules/herdr_executor"
6
+ require "ace/assign/authority/ordinary_peer"
7
+ require_relative "ordinary_owner_config"
8
+
9
+ module Ace
10
+ module Lab
11
+ module Molecules
12
+ # Read-only use of Herdr's existing native protocol. Root selection pins
13
+ # the endpoint inode and live server; no ambient CLI environment chooses
14
+ # the daemon, and no protected runtime guarantees are borrowed here.
15
+ class OrdinaryNativeRead
16
+ def initialize(config:, deadline:, peer: Ace::Assign::Authority::OrdinaryPeer.new)
17
+ raise ArgumentError, "native reader needs installed selection" unless config.is_a?(OrdinaryOwnerConfig)
18
+ @config, @deadline, @peer = config, deadline, peer
19
+ @native = config.native
20
+ end
21
+
22
+ def preflight!
23
+ result = request("ping", {})
24
+ unless result["type"] == "pong" && result.values_at("version", "protocol") == @native.values_at("version", "protocol")
25
+ unavailable!("native protocol differs from installed selection")
26
+ end
27
+ true
28
+ end
29
+
30
+ def pane_get(pane)
31
+ result = request("pane.get", {"pane_id" => pane})
32
+ unless result["type"] == "pane_info" && result["pane"].is_a?(Hash) && result["pane"]["pane_id"] == pane
33
+ unavailable!("native pane metadata differs from requested pane")
34
+ end
35
+ response(result)
36
+ end
37
+
38
+ def pane_process_info(pane)
39
+ @process_info = nil
40
+ result = request("pane.process_info", {"pane_id" => pane})
41
+ unless result["type"] == "pane_process_info" && result["process_info"].is_a?(Hash) && result["process_info"]["pane_id"] == pane
42
+ unavailable!("native process inventory differs from requested pane")
43
+ end
44
+ @process_info = result.fetch("process_info")
45
+ response(result)
46
+ end
47
+
48
+ # This per-PID native field comes from process_cwd, not the terminal's
49
+ # OSC/UI working directory. The caller separately verifies PID birth.
50
+ def process_cwd(pid)
51
+ processes = @process_info.is_a?(Hash) && @process_info["foreground_processes"]
52
+ unavailable!("native process inventory is unavailable") unless processes.is_a?(Array)
53
+ matches = processes.select { |entry| entry.is_a?(Hash) && entry["pid"] == pid }
54
+ path = matches.one? && matches.first["cwd"]
55
+ unless path.is_a?(String) && path.bytesize.between?(1, 4096) && !path.include?("\0") &&
56
+ path.start_with?("/") && File.expand_path(path) == path
57
+ unavailable!("exact native process working directory is unavailable")
58
+ end
59
+ path
60
+ end
61
+
62
+ private
63
+
64
+ def request(method, params)
65
+ verify_endpoint!
66
+ wire.connect(@native.fetch("socket_path"), deadline: @deadline) do |socket|
67
+ unless @peer.capture(socket, deadline: @deadline) == @native.fetch("server_identity")
68
+ unavailable!("native peer differs from installed server")
69
+ end
70
+ verify_endpoint!
71
+ id = SecureRandom.hex(12)
72
+ wire.write(socket, {"id" => id, "method" => method, "params" => params}, deadline: @deadline, limit: 16_384)
73
+ value = wire.read(wire::Ingress.new(socket), deadline: @deadline, limit: 65_536)
74
+ unless value.is_a?(Hash) && value.keys.sort == %w[id result] && value["id"] == id && value["result"].is_a?(Hash)
75
+ unavailable!("native read response is invalid")
76
+ end
77
+ verify_endpoint!
78
+ @peer.live!(@native.fetch("server_identity"), deadline: @deadline)
79
+ value.fetch("result")
80
+ end
81
+ rescue SystemCallError, IOError, KeyError, Ace::Assign::AttemptErrors::UnauthorizedIdentity
82
+ unavailable!("native read is unavailable")
83
+ end
84
+
85
+ def verify_endpoint!
86
+ @config.verify_selection!
87
+ path = @native.fetch("socket_path")
88
+ wire.root_path!(File.dirname(path), directory: true)
89
+ unless wire.socket_identity(path) == @native.fetch("socket_identity") && (File.lstat(path).mode & 0o007).zero?
90
+ unavailable!("native endpoint generation or access differs")
91
+ end
92
+ end
93
+
94
+ def response(result)
95
+ Ace::Herdr::Molecules::ExecutionResult.new(stdout: JSON.generate({"result" => result}), stderr: "", success: true, exit_code: 0)
96
+ end
97
+
98
+ def wire = Ace::Runtime::Molecules::ProtectedSocket
99
+
100
+ def unavailable!(reason)
101
+ raise Ace::Runtime::RuntimeUnavailableError, reason
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/assign/authority/ordinary_peer"
4
+ require "ace/assign/molecules/execution_identity_resolver"
5
+ require "ace/herdr"
6
+ require_relative "ordinary_owner_config"
7
+ require_relative "ordinary_native_read"
8
+ require_relative "ordinary_worktree_read"
9
+
10
+ module Ace
11
+ module Lab
12
+ module Molecules
13
+ # The source-selected ordinary peer policy. Native selectors are hints;
14
+ # only actual ancestry in the installed project's Herdr grants attribution.
15
+ class OrdinaryOwnerAdmission
16
+ def initialize(config:, peer: Ace::Assign::Authority::OrdinaryPeer.new, runtime: nil)
17
+ raise ArgumentError, "ordinary owner requires validated installed selection" unless config.is_a?(OrdinaryOwnerConfig)
18
+ @config, @peer = config, peer
19
+ @runtime = runtime
20
+ end
21
+
22
+ def service = @config.service
23
+ def verify_listener!
24
+ @config.verify_listener!
25
+ OrdinaryNativeRead.new(config: @config, deadline: default_deadline, peer: @peer).preflight!
26
+ end
27
+ def peer(socket, deadline:) = @peer.capture(socket, deadline: deadline)
28
+
29
+ def authorize!(request:, peer:, deadline: default_deadline)
30
+ @peer.live!(peer, deadline: deadline)
31
+ unless request.fetch("project_id") == @config.project
32
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary request belongs to another project"
33
+ end
34
+ @config.participant!(peer)
35
+ pane = request.fetch("params", {})["pane"]
36
+ # Reads need project visibility only. Every mutating lifecycle route
37
+ # validates a pane; supervisor authority additionally requires actual
38
+ # caller ancestry in one of the root-installed native placements.
39
+ return :worker unless pane
40
+ identity!(peer: peer, pane: pane, deadline: deadline).role == "coordinator" ? :supervisor : :worker
41
+ end
42
+
43
+ def identity!(peer:, pane:, deadline: default_deadline)
44
+ observe_identity(peer: peer, pane: pane, deadline: deadline, with_cwd: false).first
45
+ end
46
+
47
+ # The caller's claimed head is only a comparison. The accepted start
48
+ # uses the observed worker's checkout and process, never daemon cwd.
49
+ def execution!(peer:, pane:, head:, deadline: default_deadline)
50
+ unless head.is_a?(String) && head.match?(/\A[0-9a-f]{40}\z/)
51
+ raise ArgumentError, "ordinary start HEAD is invalid"
52
+ end
53
+ identity, cwd = observe_identity(peer: peer, pane: pane, deadline: deadline, with_cwd: true)
54
+ checkout = OrdinaryWorktreeRead.new(root: @config.worktrees.fetch("root"),
55
+ common_dir: @config.worktrees.fetch("common_dir"), deadline: deadline).observe(cwd)
56
+ after, after_cwd = observe_identity(peer: peer, pane: pane, deadline: deadline, with_cwd: true)
57
+ unless after == identity && after_cwd == cwd && checkout.fetch("head") == head
58
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary worker process, working directory or HEAD changed"
59
+ end
60
+ {identity: identity, checkout: checkout}
61
+ end
62
+
63
+ # Read the original canonical native owner for a trusted consumer.
64
+ # The binding must come from accepted history, never requester fields.
65
+ # This observes ownership; it does not grant the target's privileges to
66
+ # the caller or manufacture a kernel-authenticated connection identity.
67
+ def recorded_binding!(binding:, caller_pid: nil, deadline: default_deadline)
68
+ observe_recorded_binding(binding, caller_pid, deadline).first
69
+ end
70
+
71
+ def recorded_execution!(binding:, head:, deadline: default_deadline)
72
+ before, cwd = observe_recorded_binding(binding, nil, deadline)
73
+ checkout = OrdinaryWorktreeRead.new(root: @config.worktrees.fetch("root"),
74
+ common_dir: @config.worktrees.fetch("common_dir"), deadline: deadline).observe(cwd)
75
+ after, after_cwd = observe_recorded_binding(binding, nil, deadline)
76
+ unless before == after && cwd == after_cwd && checkout.fetch("head") == head
77
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary recorded worker or HEAD changed"
78
+ end
79
+ {runtime_binding: after, checkout: checkout}
80
+ end
81
+
82
+ private
83
+
84
+ def observe_recorded_binding(binding, caller_pid, deadline)
85
+ @config.verify_selection!
86
+ uid = binding.fetch("process_identity").fetch("uid")
87
+ participant = @config.selected_principal!(uid)
88
+ unless participant && binding["runtime"] == participant.fetch("runtime") && binding["session"] == participant.fetch("workspace")
89
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary recorded participant is no longer selected"
90
+ end
91
+ native, runtime = native_runtime(deadline)
92
+ actual = runtime.process_binding(pane: binding.fetch("pane"), caller_pid: caller_pid || binding.fetch("process_identity").fetch("pid"))
93
+ unless actual == binding
94
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary recorded native owner is unavailable or changed"
95
+ end
96
+ cwd = native.process_cwd(binding.fetch("process_identity").fetch("pid"))
97
+ @config.selected_principal!(uid)
98
+ [actual, cwd]
99
+ end
100
+
101
+ def native_runtime(deadline)
102
+ return [@runtime, @runtime] if @runtime
103
+ observation = Ace::Herdr::Molecules::BoundedObservation.new(deadline: deadline, binary: @config.runtime_binary)
104
+ native = OrdinaryNativeRead.new(config: @config, deadline: deadline, peer: @peer)
105
+ native.preflight!
106
+ [native, Ace::Herdr::Organisms::RuntimeAdapter.new(executor: native, env: {},
107
+ process_identity: Ace::Runtime::Molecules::ProcessIdentity.new(executor: observation))]
108
+ end
109
+
110
+ def observe_identity(peer:, pane:, deadline:, with_cwd:)
111
+ @peer.live!(peer, deadline: deadline)
112
+ participant = @config.participant!(peer)
113
+ unless pane.is_a?(String) && pane.bytesize.between?(1, 128) && !pane.include?("\0")
114
+ raise ArgumentError, "ordinary native pane selector is invalid"
115
+ end
116
+ native, runtime = native_runtime(deadline)
117
+ binding = runtime.process_binding(pane: pane, caller_pid: peer.fetch("pid"))
118
+ unless binding && binding["runtime"] == participant.fetch("runtime") &&
119
+ binding["session"] == participant.fetch("workspace") && binding["pane"] == pane &&
120
+ binding.dig("process_identity", "uid") == peer.fetch("uid")
121
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary native owner is unavailable or differs"
122
+ end
123
+ @peer.live!(peer, deadline: deadline)
124
+ @config.participant!(peer)
125
+ # Ordinary native agents may share an OS account. Preserve that actor
126
+ # for HITL attribution; privilege follows observed native placement,
127
+ # not a claimed role or an invented per-agent account alias.
128
+ role = participant.fetch("supervisor_panes").include?(binding.fetch("pane")) ? "coordinator" : "worker"
129
+ identity = Ace::Assign::Molecules::ExecutionIdentityResolver::Identity.new(
130
+ actor: participant.fetch("actor"), role: role, runtime: participant.fetch("runtime"),
131
+ adapter: "ordinary", process_pid: binding.fetch("process_identity").fetch("pid"), runtime_binding: binding)
132
+ [identity, with_cwd ? native.process_cwd(identity.process_pid) : nil]
133
+ end
134
+
135
+ def default_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "json"
5
+ require "digest"
6
+ require "etc"
7
+ require "ace/assign/authority/private_directory"
8
+ require "ace/assign/authority/protected_assignment_context"
9
+ require "ace/assign/molecules/installed_journal_read"
10
+ require "ace/runtime/molecules/protected_artifact_set"
11
+ require "ace/hitl/lifecycle/store"
12
+ require_relative "../errors"
13
+
14
+ module Ace
15
+ module Lab
16
+ module Molecules
17
+ # Placement and principals only. Source selects lifecycle operations.
18
+ # Root installs this independently of requester worktrees/config cascades.
19
+ class OrdinaryOwnerConfig
20
+ PATH = "/etc/lab/ace-lab/assign-owners.yml"
21
+ ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
22
+ PROJECT_KEYS = %w[service journal assignment_root lifecycle_root runtime_binary native worktrees participants hitl].freeze
23
+ SERVICE_KEYS = %w[uid gid socket_path state_root].freeze
24
+ JOURNAL_KEYS = %w[repository ref checkout_root].freeze
25
+ PARTICIPANT_KEYS = %w[gid actor runtime workspace supervisor_panes].freeze
26
+ PANE = /\A[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,127}\z/
27
+ NATIVE_KEYS = %w[socket_path socket_identity server_identity version protocol].freeze
28
+ HITL_KEYS = %w[root socket_path group grants_path].freeze
29
+ HITL_OPTIONAL_KEYS = %w[tls_authority].freeze
30
+
31
+ def self.load(project:, path: PATH)
32
+ reader = Ace::Runtime::Molecules::ProtectedArtifactSet.new(file_limit: 65_536, total_limit: 65_536)
33
+ bytes = reader.with do |held|
34
+ content, = held.read_path!(path)
35
+ held.verify_unchanged!
36
+ content
37
+ end
38
+ new(YAML.safe_load(bytes, aliases: false), project: project, source: path)
39
+ rescue Psych::Exception, Ace::Runtime::Error
40
+ raise InvalidConfigurationError, "ordinary assignment owner configuration is unavailable: #{path}"
41
+ end
42
+
43
+ attr_reader :project, :service, :journal, :assignment_root, :lifecycle_root,
44
+ :runtime_binary, :native, :worktrees, :participants, :hitl, :source
45
+
46
+ def initialize(document, project:, source:)
47
+ unless document.is_a?(Hash) && document.keys.sort == %w[projects version] && document["version"] == 1 &&
48
+ document["projects"].is_a?(Hash) && document["projects"].keys.all? { |id| id.is_a?(String) && ID.match?(id) } &&
49
+ project.is_a?(String) && ID.match?(project)
50
+ invalid!
51
+ end
52
+ selected = document.fetch("projects")[project]
53
+ validate_principal_separation!(document.fetch("projects"))
54
+ invalid! unless selected.is_a?(Hash) && selected.keys.sort == PROJECT_KEYS.sort
55
+ invalid! unless absolute?(source)
56
+ @project, @source = project.dup.freeze, source.dup.freeze
57
+ # Validate a detached immutable copy; callers cannot mutate trusted selection.
58
+ selected = JSON.parse(JSON.generate(selected))
59
+ @service, @journal, @participants = selected.values_at("service", "journal", "participants")
60
+ invalid! unless @service.is_a?(Hash) && @service.keys.sort == SERVICE_KEYS.sort &&
61
+ %w[uid gid].all? { |key| uid?(@service[key]) } &&
62
+ %w[socket_path state_root].all? { |key| absolute?(@service[key]) }
63
+ invalid! unless @journal.is_a?(Hash) && @journal.keys.sort == JOURNAL_KEYS.sort &&
64
+ %w[repository checkout_root].all? { |key| absolute?(@journal[key]) } &&
65
+ @journal["ref"].is_a?(String) && @journal["ref"].match?(%r{\Arefs/[a-zA-Z0-9_-]+(?:/[a-zA-Z0-9_-]+)+\z})
66
+ @assignment_root, @lifecycle_root, @runtime_binary = selected.values_at("assignment_root", "lifecycle_root", "runtime_binary")
67
+ invalid! unless [@assignment_root, @lifecycle_root, @runtime_binary].all? { |path| absolute?(path) }
68
+ @native = selected.fetch("native")
69
+ invalid! unless @native.is_a?(Hash) && @native.keys.sort == NATIVE_KEYS.sort && absolute?(@native["socket_path"]) &&
70
+ @native["protocol"] == 22 && @native["protocol"].is_a?(Integer) && @native["version"].is_a?(String) &&
71
+ @native["version"].match?(/\A\d+\.\d+\.\d+\z/)
72
+ server = @native["server_identity"]
73
+ invalid! unless server.is_a?(Hash) && server.keys.sort == %w[gid host pid started_at uid] &&
74
+ %w[pid uid gid].all? { |key| uid?(server[key]) } &&
75
+ %w[host started_at].all? { |key| server[key].is_a?(String) && server[key].bytesize.between?(1, 512) && !server[key].include?("\0") }
76
+ endpoint = @native["socket_identity"]
77
+ invalid! unless endpoint.is_a?(Array) && endpoint.size == 3 && endpoint.all? { |value| value.is_a?(Integer) && value >= 0 } && endpoint.last.zero?
78
+ @worktrees = selected.fetch("worktrees")
79
+ invalid! unless @worktrees.is_a?(Hash) && @worktrees.keys.sort == %w[common_dir root] && @worktrees.values.all? { |path| absolute?(path) }
80
+ @hitl = selected.fetch("hitl")
81
+ invalid! unless @hitl.is_a?(Hash) && (HITL_KEYS - @hitl.keys).empty? &&
82
+ (@hitl.keys - HITL_KEYS - HITL_OPTIONAL_KEYS).empty? &&
83
+ %w[root socket_path grants_path].all? { |key| absolute?(@hitl[key]) } &&
84
+ (!@hitl.key?("tls_authority") || absolute?(@hitl["tls_authority"])) &&
85
+ @hitl["group"].is_a?(String) && ID.match?(@hitl["group"]) &&
86
+ [@service.fetch("socket_path"), @native.fetch("socket_path"), @hitl.fetch("socket_path")].uniq.size == 3
87
+ private_paths = [@assignment_root, @lifecycle_root, @journal.fetch("checkout_root"), @hitl.fetch("root")]
88
+ invalid! unless private_paths.all? { |path| path.start_with?(@service.fetch("state_root") + "/") } &&
89
+ private_paths.uniq.size == private_paths.size && private_paths.combination(2).none? { |a, b| a.start_with?(b + "/") || b.start_with?(a + "/") }
90
+ invalid! if @journal.fetch("repository").start_with?(@service.fetch("state_root") + "/") ||
91
+ @service.fetch("state_root").start_with?(@journal.fetch("repository") + "/") ||
92
+ @service.fetch("state_root") == @journal.fetch("repository")
93
+ invalid! unless @participants.is_a?(Hash) && !@participants.empty? && @participants.size <= 64
94
+ @participants.each do |uid, participant|
95
+ invalid! unless uid.is_a?(String) && uid.match?(/\A[1-9][0-9]{0,9}\z/) && uid?(uid.to_i) && uid.to_i != @service.fetch("uid") &&
96
+ participant.is_a?(Hash) && participant.keys.sort == PARTICIPANT_KEYS.sort && uid?(participant["gid"]) &&
97
+ participant["actor"].is_a?(String) && ID.match?(participant["actor"]) &&
98
+ participant["runtime"] == "herdr" && participant["workspace"].is_a?(String) && ID.match?(participant["workspace"]) &&
99
+ participant["supervisor_panes"].is_a?(Array) && participant["supervisor_panes"].size <= 16 &&
100
+ participant["supervisor_panes"].uniq == participant["supervisor_panes"] &&
101
+ participant["supervisor_panes"].all? { |pane| pane.is_a?(String) && PANE.match?(pane) }
102
+ end
103
+ invalid! unless @participants.values.map { |value| value.fetch("actor") }.uniq.size == @participants.size
104
+ @fingerprint = Digest::SHA256.hexdigest(Ace::Assign::Atoms::EvidenceDigest.canonical_json(selected))
105
+ freeze_value(selected)
106
+ freeze
107
+ end
108
+
109
+ def verify_selection!
110
+ current = self.class.load(project: @project, path: @source)
111
+ unless current.fingerprint == @fingerprint
112
+ raise InvalidConfigurationError, "ordinary assignment owner selection changed; restart with installed configuration"
113
+ end
114
+ true
115
+ end
116
+
117
+ def verify_listener!
118
+ verify_owner!
119
+ unless [Process.uid, Process.euid, Process.gid, Process.egid] ==
120
+ [@service.fetch("uid"), @service.fetch("uid"), @service.fetch("gid"), @service.fetch("gid")]
121
+ raise InvalidConfigurationError, "ordinary assignment owner process differs"
122
+ end
123
+ ([service.fetch("uid")] + participants.keys.map(&:to_i)).each { |uid| exclude_protected!(uid) }
124
+ [@service.fetch("state_root"), @assignment_root, @lifecycle_root, @journal.fetch("checkout_root")].each do |path|
125
+ Ace::Assign::Authority::PrivateDirectory.verify!(path)
126
+ end
127
+ verify_hitl_root!
128
+ wire = Ace::Runtime::Molecules::ProtectedSocket
129
+ wire.root_path!(@runtime_binary)
130
+ invalid! unless File.file?(@runtime_binary) && File.executable?(@runtime_binary)
131
+ invalid! unless File.lstat(@journal.fetch("repository")).uid == @service.fetch("uid")
132
+ reader = Ace::Assign::Molecules::InstalledJournalRead.new(repo_root: @journal.fetch("repository"),
133
+ ref: @journal.fetch("ref"), owner_uid: @service.fetch("uid"))
134
+ reader.with(deadline: wire.deadline(30)) { |_snapshot| true }
135
+ true
136
+ end
137
+
138
+ def verify_owner!
139
+ verify_selection!
140
+ exclude_protected!(@service.fetch("uid"))
141
+ true
142
+ end
143
+
144
+ def participant!(peer)
145
+ participant = selected_principal!(peer.fetch("uid"))
146
+ unless participant.fetch("gid") == peer.fetch("gid")
147
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary participant group differs"
148
+ end
149
+ participant
150
+ end
151
+
152
+ # Selection only: consumers of canonical native history have an
153
+ # observed process UID, not a new authenticated socket from that owner.
154
+ def selected_principal!(uid)
155
+ verify_owner!
156
+ exclude_protected!(uid)
157
+ participant = @participants[uid.to_s]
158
+ unless participant
159
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary participant is not installed for this project"
160
+ end
161
+ # HITL and the canonical coordinator use the authenticated passwd
162
+ # principal. An unrelated display label cannot replace that identity.
163
+ unless Etc.getpwuid(uid).name == participant.fetch("actor")
164
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary actor differs from installed OS principal"
165
+ end
166
+ participant
167
+ rescue ArgumentError
168
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary OS principal is unavailable"
169
+ end
170
+
171
+ protected
172
+
173
+ attr_reader :fingerprint
174
+
175
+ private
176
+
177
+ # HITL maintains its own layout (root 0711, private request/secret
178
+ # directories). The already-verified private state_root encloses it.
179
+ # Requiring a 0700 HITL leaf would reject every normal service restart.
180
+ def verify_hitl_root!
181
+ root = @hitl.fetch("root")
182
+ Ace::Runtime::Molecules::ProtectedSocket.root_path!(root, directory: true, owner: @service.fetch("uid"))
183
+ unless (File.stat(root).mode & 0o7777) == Ace::Hitl::Lifecycle::Store::ROOT_MODE
184
+ raise InvalidConfigurationError, "ordinary HITL store root must use the maintained store mode"
185
+ end
186
+ true
187
+ end
188
+
189
+ def validate_principal_separation!(projects)
190
+ invalid! unless projects.size.between?(1, 64)
191
+ owners, callers = [], []
192
+ projects.each_value do |entry|
193
+ invalid! unless entry.is_a?(Hash) && entry["service"].is_a?(Hash) &&
194
+ uid?(entry["service"]["uid"]) && entry["participants"].is_a?(Hash)
195
+ owners << entry.fetch("service").fetch("uid")
196
+ entry.fetch("participants").each_key do |uid|
197
+ invalid! unless uid.is_a?(String) && uid.match?(/\A[1-9][0-9]{0,9}\z/) && uid?(uid.to_i)
198
+ callers << uid.to_i
199
+ end
200
+ end
201
+ invalid! unless (owners & callers).empty?
202
+ end
203
+
204
+ def exclude_protected!(uid)
205
+ context = Ace::Assign::Authority::ProtectedAssignmentContext.load(uid: uid)
206
+ if context.protected_participant?
207
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "protected principal cannot use ordinary assignment authority"
208
+ end
209
+ end
210
+
211
+ def absolute?(value)
212
+ value.is_a?(String) && value.bytesize.between?(1, 4096) && !value.include?("\0") &&
213
+ value.start_with?("/") && File.expand_path(value) == value
214
+ end
215
+
216
+ def uid?(value) = value.is_a?(Integer) && value.between?(1, 2**31 - 1)
217
+
218
+ def freeze_value(value)
219
+ case value
220
+ when Hash then value.each { |key, item| key.freeze; freeze_value(item) }
221
+ when Array then value.each { |item| freeze_value(item) }
222
+ end
223
+ value.freeze
224
+ end
225
+
226
+ def invalid!
227
+ raise InvalidConfigurationError, "ordinary assignment owner configuration is invalid"
228
+ end
229
+ end
230
+ end
231
+ end
232
+ end
@@ -3,6 +3,7 @@
3
3
  require "yaml"
4
4
  require_relative "../errors"
5
5
  require_relative "service_policy"
6
+ require_relative "grant_resolver"
6
7
 
7
8
  module Ace
8
9
  module Lab
@@ -16,26 +17,32 @@ module Ace
16
17
  class OrdinaryReceiverConfig
17
18
  KEYS = %w[project receiver_id socket_path receiver_uid principal principal_uid
18
19
  repo_path policy_path execution_identity].freeze
20
+ OPTIONAL_KEYS = %w[socket_gid forge_config_path].freeze
19
21
  IDENTITY_KEYS = %w[checkout_id worktree_id branch].freeze
20
22
  ID = ServicePolicy::ID
21
23
 
22
24
  def self.load(path)
23
25
  raise Ace::Lab::InvalidConfigurationError, "receiver config path is required" unless path.is_a?(String) && !path.empty?
24
- document = YAML.safe_load_file(path, permitted_classes: [Date], aliases: false)
26
+ bytes = GrantResolver.read_verified(path)
27
+ raise Ace::Lab::InvalidConfigurationError, "receiver configuration is missing" unless bytes
28
+ document = YAML.safe_load(bytes, permitted_classes: [Date], aliases: false)
25
29
  new(document, source: path)
26
30
  rescue Errno::ENOENT, Errno::EACCES, Psych::Exception
27
31
  raise Ace::Lab::InvalidConfigurationError, "receiver configuration is unreadable: #{path}"
28
32
  end
29
33
 
30
34
  attr_reader :project, :receiver_id, :socket_path, :receiver_uid, :principal,
31
- :principal_uid, :repo_path, :policy_path, :execution_identity, :source
35
+ :principal_uid, :repo_path, :policy_path, :execution_identity, :source, :socket_gid, :forge_config_path
32
36
 
33
37
  def initialize(document, source: nil)
34
- unless document.is_a?(Hash) && (document.keys - KEYS).empty? &&
38
+ unless document.is_a?(Hash) && (document.keys - KEYS - OPTIONAL_KEYS).empty? &&
35
39
  (KEYS - document.keys).empty?
36
40
  raise Ace::Lab::InvalidConfigurationError,
37
- "receiver configuration must contain exactly: #{KEYS.join(', ')}"
41
+ "receiver configuration requires #{KEYS.join(', ')}; optional: #{OPTIONAL_KEYS.join(', ')}"
38
42
  end
43
+ @socket_gid = uid!(document.fetch("socket_gid"), "socket_gid") if document.key?("socket_gid")
44
+ @forge_config_path = absolute!(document.fetch("forge_config_path"), "forge_config_path") if
45
+ document.key?("forge_config_path")
39
46
  @source = source
40
47
  @project = string!(document.fetch("project"), "project")
41
48
  @receiver_id = string!(document.fetch("receiver_id"), "receiver_id")
@@ -44,6 +51,7 @@ module Ace
44
51
  @repo_path = absolute!(document.fetch("repo_path"), "repo_path")
45
52
  @policy_path = absolute!(document.fetch("policy_path"), "policy_path")
46
53
  @receiver_uid = uid!(document.fetch("receiver_uid"), "receiver_uid")
54
+ raise Ace::Lab::InvalidConfigurationError, "ordinary receiver must have a non-root owner" if @receiver_uid.zero?
47
55
  @principal_uid = uid!(document.fetch("principal_uid"), "principal_uid")
48
56
  identity = document.fetch("execution_identity")
49
57
  unless identity.is_a?(Hash) && (identity.keys - IDENTITY_KEYS).empty? &&
@@ -9,7 +9,7 @@ module Ace
9
9
  # Admission transport between the ordinary fixed merge producer and the
10
10
  # installed ordinary receiver: one bounded JSON document each way over
11
11
  # a configured unix stream socket with a fixed deadline. Authentication
12
- # is mutual through SO_PEERCRED — the producer verifies the receiver's
12
+ # is mutual through OS peer credentials — the producer verifies the receiver's
13
13
  # configured UID and transmits the verified local caller identity, so
14
14
  # no caller-selected credential, token or endpoint exists. This is not
15
15
  # an HTTP transport; a lost or unauthenticated response is a
@@ -33,19 +33,19 @@ module Ace
33
33
  def submit(document)
34
34
  payload = JSON.generate(document)
35
35
  raise OrdinaryUnavailable, "request document exceeds the wire bound" if payload.bytesize > LIMIT
36
- socket = Socket.new(:UNIX, :STREAM, 0)
36
+ socket = nil
37
37
  begin
38
- socket.connect(Addrinfo.unix(@socket_path))
38
+ socket = UNIXSocket.new(@socket_path)
39
39
  verify_peer!(socket)
40
40
  write_all!(socket, "#{HANDSHAKE} #{payload.bytesize}\n#{payload}")
41
41
  response = read_response!(socket)
42
42
  rescue OrdinaryUnavailable
43
43
  raise
44
- rescue SystemCallError, IOError => e
44
+ rescue SystemCallError, IOError, SocketError, NotImplementedError => e
45
45
  raise OrdinaryUnavailable, "receiver transport failed: #{e.class}"
46
46
  ensure
47
47
  begin
48
- socket.close
48
+ socket&.close
49
49
  rescue IOError
50
50
  nil
51
51
  end
@@ -58,10 +58,7 @@ module Ace
58
58
  private
59
59
 
60
60
  def verify_peer!(socket)
61
- credentials = socket.getsockopt(:SOCKET, :PEERCRED)
62
- # struct ucred is {pid, uid, gid} on Linux; the producer binds the
63
- # receiver to its installed UID and nothing else.
64
- _peer_pid, peer_uid, = credentials.unpack("L3")
61
+ peer_uid, = socket.getpeereid
65
62
  return if peer_uid == @receiver_uid
66
63
  raise OrdinaryUnavailable, "receiver endpoint identity is not the installed receiver"
67
64
  end