ace-lab 0.9.0 → 0.13.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.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +363 -104
  3. data/README.md +66 -100
  4. data/docs/usage.md +529 -592
  5. data/lib/ace/lab/atoms/install_schema.rb +373 -0
  6. data/lib/ace/lab/cli/commands/install.rb +161 -0
  7. data/lib/ace/lab/cli/commands/prepare.rb +118 -0
  8. data/lib/ace/lab/cli/commands/project_add.rb +101 -0
  9. data/lib/ace/lab/cli/commands/status.rb +109 -0
  10. data/lib/ace/lab/cli/commands/verify.rb +115 -0
  11. data/lib/ace/lab/cli.rb +21 -23
  12. data/lib/ace/lab/install_manifest_path.rb +23 -0
  13. data/lib/ace/lab/models/status_report.rb +38 -0
  14. data/lib/ace/lab/molecules/account_provisioner.rb +196 -0
  15. data/lib/ace/lab/molecules/config_state_classifier.rb +145 -0
  16. data/lib/ace/lab/molecules/directory_provisioner.rb +208 -0
  17. data/lib/ace/lab/molecules/grant_provisioner.rb +250 -0
  18. data/lib/ace/lab/molecules/grant_resolver.rb +34 -23
  19. data/lib/ace/lab/molecules/hitl_routes.rb +263 -0
  20. data/lib/ace/lab/molecules/install_manifest_loader.rb +64 -0
  21. data/lib/ace/lab/molecules/key_provisioner.rb +736 -0
  22. data/lib/ace/lab/molecules/lab_state_scanner.rb +55 -0
  23. data/lib/ace/lab/molecules/overseer_provisioner.rb +188 -0
  24. data/lib/ace/lab/molecules/project_registry.rb +251 -0
  25. data/lib/ace/lab/molecules/repository_provisioner.rb +141 -0
  26. data/lib/ace/lab/molecules/role_verifier.rb +223 -0
  27. data/lib/ace/lab/organisms/install.rb +213 -0
  28. data/lib/ace/lab/organisms/prepare.rb +156 -0
  29. data/lib/ace/lab/organisms/project_add.rb +170 -0
  30. data/lib/ace/lab/organisms/status_service.rb +70 -0
  31. data/lib/ace/lab/organisms/verify.rb +768 -0
  32. data/lib/ace/lab/version.rb +1 -1
  33. data/lib/ace/lab.rb +34 -6
  34. metadata +34 -108
  35. data/lib/ace/lab/atoms/protected_publication_input.rb +0 -50
  36. data/lib/ace/lab/atoms/protected_workspace_prune_input.rb +0 -114
  37. data/lib/ace/lab/atoms/protected_workspace_prune_preview.rb +0 -86
  38. data/lib/ace/lab/atoms/service_input.rb +0 -92
  39. data/lib/ace/lab/cli/commands/assignment.rb +0 -92
  40. data/lib/ace/lab/cli/commands/command_execute.rb +0 -28
  41. data/lib/ace/lab/cli/commands/run.rb +0 -65
  42. data/lib/ace/lab/cli/commands/service.rb +0 -240
  43. data/lib/ace/lab/models/service_receipt.rb +0 -18
  44. data/lib/ace/lab/molecules/command_owner_admission.rb +0 -183
  45. data/lib/ace/lab/molecules/command_policy.rb +0 -158
  46. data/lib/ace/lab/molecules/hitl_authorizer.rb +0 -63
  47. data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +0 -131
  48. data/lib/ace/lab/molecules/ordinary_listener.rb +0 -262
  49. data/lib/ace/lab/molecules/ordinary_merge_read.rb +0 -45
  50. data/lib/ace/lab/molecules/ordinary_native_read.rb +0 -106
  51. data/lib/ace/lab/molecules/ordinary_owner_admission.rb +0 -152
  52. data/lib/ace/lab/molecules/ordinary_owner_config.rb +0 -232
  53. data/lib/ace/lab/molecules/ordinary_receiver_config.rb +0 -94
  54. data/lib/ace/lab/molecules/ordinary_socket.rb +0 -126
  55. data/lib/ace/lab/molecules/ordinary_worktree_read.rb +0 -216
  56. data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +0 -163
  57. data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +0 -165
  58. data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +0 -239
  59. data/lib/ace/lab/molecules/protected_publication_publisher.rb +0 -110
  60. data/lib/ace/lab/molecules/protected_service_handler.rb +0 -43
  61. data/lib/ace/lab/molecules/protected_service_policy.rb +0 -156
  62. data/lib/ace/lab/molecules/service_executor.rb +0 -99
  63. data/lib/ace/lab/molecules/service_policy.rb +0 -289
  64. data/lib/ace/lab/organisms/authority_composition.rb +0 -87
  65. data/lib/ace/lab/organisms/command_executor.rb +0 -220
  66. data/lib/ace/lab/organisms/command_run_client.rb +0 -131
  67. data/lib/ace/lab/organisms/ordinary_assignment_client.rb +0 -120
  68. data/lib/ace/lab/organisms/ordinary_authority_composition.rb +0 -153
  69. data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +0 -679
  70. data/lib/ace/lab/organisms/ordinary_merge_request.rb +0 -456
  71. data/lib/ace/lab/organisms/protected_cleanup_owner.rb +0 -361
  72. data/lib/ace/lab/organisms/protected_service_client.rb +0 -129
  73. data/lib/ace/lab/organisms/protected_service_listener.rb +0 -181
  74. data/lib/ace/lab/organisms/protected_service_publication.rb +0 -268
  75. data/lib/ace/lab/organisms/protected_service_receiver.rb +0 -267
  76. data/lib/ace/lab/organisms/protected_service_recovery.rb +0 -185
  77. data/lib/ace/lab/organisms/protected_service_request.rb +0 -128
  78. data/lib/ace/lab/organisms/protected_service_worker.rb +0 -89
  79. data/lib/ace/lab/organisms/service_request_service.rb +0 -249
@@ -1,106 +0,0 @@
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
@@ -1,152 +0,0 @@
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
- def supervisor_ended!(binding:, deadline: default_deadline)
48
- @config.verify_selection!
49
- process = binding.fetch("process_identity")
50
- @config.selected_principal!(process.fetch("uid"))
51
- observation = Ace::Herdr::Molecules::BoundedObservation.new(deadline: deadline, binary: @config.runtime_binary)
52
- result = Ace::Runtime::Molecules::ProcessIdentity.new(executor: observation).generation_status(process)
53
- unless result.fetch("liveness") == "ended"
54
- raise Ace::Assign::AttemptErrors::EvidenceUnavailable, "original supervisor process generation is live or unknown"
55
- end
56
- @config.verify_selection!
57
- result
58
- end
59
-
60
- # The caller's claimed head is only a comparison. The accepted start
61
- # uses the observed worker's checkout and process, never daemon cwd.
62
- def execution!(peer:, pane:, head:, deadline: default_deadline)
63
- unless head.is_a?(String) && head.match?(/\A[0-9a-f]{40}\z/)
64
- raise ArgumentError, "ordinary start HEAD is invalid"
65
- end
66
- identity, cwd = observe_identity(peer: peer, pane: pane, deadline: deadline, with_cwd: true)
67
- checkout = OrdinaryWorktreeRead.new(root: @config.worktrees.fetch("root"),
68
- common_dir: @config.worktrees.fetch("common_dir"), deadline: deadline).observe(cwd)
69
- after, after_cwd = observe_identity(peer: peer, pane: pane, deadline: deadline, with_cwd: true)
70
- unless after == identity && after_cwd == cwd && checkout.fetch("head") == head
71
- raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary worker process, working directory or HEAD changed"
72
- end
73
- {identity: identity, checkout: checkout}
74
- end
75
-
76
- # Read the original canonical native owner for a trusted consumer.
77
- # The binding must come from accepted history, never requester fields.
78
- # This observes ownership; it does not grant the target's privileges to
79
- # the caller or manufacture a kernel-authenticated connection identity.
80
- def recorded_binding!(binding:, caller_pid: nil, deadline: default_deadline)
81
- observe_recorded_binding(binding, caller_pid, deadline).first
82
- end
83
-
84
- def recorded_execution!(binding:, head:, deadline: default_deadline)
85
- before, cwd = observe_recorded_binding(binding, nil, deadline)
86
- checkout = OrdinaryWorktreeRead.new(root: @config.worktrees.fetch("root"),
87
- common_dir: @config.worktrees.fetch("common_dir"), deadline: deadline).observe(cwd)
88
- after, after_cwd = observe_recorded_binding(binding, nil, deadline)
89
- unless before == after && cwd == after_cwd && checkout.fetch("head") == head
90
- raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary recorded worker or HEAD changed"
91
- end
92
- {runtime_binding: after, checkout: checkout}
93
- end
94
-
95
- private
96
-
97
- def observe_recorded_binding(binding, caller_pid, deadline)
98
- @config.verify_selection!
99
- uid = binding.fetch("process_identity").fetch("uid")
100
- participant = @config.selected_principal!(uid)
101
- unless participant && binding["runtime"] == participant.fetch("runtime") && binding["session"] == participant.fetch("workspace")
102
- raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary recorded participant is no longer selected"
103
- end
104
- native, runtime = native_runtime(deadline)
105
- actual = runtime.process_binding(pane: binding.fetch("pane"), caller_pid: caller_pid || binding.fetch("process_identity").fetch("pid"))
106
- unless actual == binding
107
- raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary recorded native owner is unavailable or changed"
108
- end
109
- cwd = native.process_cwd(binding.fetch("process_identity").fetch("pid"))
110
- @config.selected_principal!(uid)
111
- [actual, cwd]
112
- end
113
-
114
- def native_runtime(deadline)
115
- return [@runtime, @runtime] if @runtime
116
- observation = Ace::Herdr::Molecules::BoundedObservation.new(deadline: deadline, binary: @config.runtime_binary)
117
- native = OrdinaryNativeRead.new(config: @config, deadline: deadline, peer: @peer)
118
- native.preflight!
119
- [native, Ace::Herdr::Organisms::RuntimeAdapter.new(executor: native, env: {},
120
- process_identity: Ace::Runtime::Molecules::ProcessIdentity.new(executor: observation))]
121
- end
122
-
123
- def observe_identity(peer:, pane:, deadline:, with_cwd:)
124
- @peer.live!(peer, deadline: deadline)
125
- participant = @config.participant!(peer)
126
- unless pane.is_a?(String) && pane.bytesize.between?(1, 128) && !pane.include?("\0")
127
- raise ArgumentError, "ordinary native pane selector is invalid"
128
- end
129
- native, runtime = native_runtime(deadline)
130
- binding = runtime.process_binding(pane: pane, caller_pid: peer.fetch("pid"))
131
- unless binding && binding["runtime"] == participant.fetch("runtime") &&
132
- binding["session"] == participant.fetch("workspace") && binding["pane"] == pane &&
133
- binding.dig("process_identity", "uid") == peer.fetch("uid")
134
- raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary native owner is unavailable or differs"
135
- end
136
- @peer.live!(peer, deadline: deadline)
137
- @config.participant!(peer)
138
- # Ordinary native agents may share an OS account. Preserve that actor
139
- # for HITL attribution; privilege follows observed native placement,
140
- # not a claimed role or an invented per-agent account alias.
141
- role = participant.fetch("supervisor_panes").include?(binding.fetch("pane")) ? "coordinator" : "worker"
142
- identity = Ace::Assign::Molecules::ExecutionIdentityResolver::Identity.new(
143
- actor: participant.fetch("actor"), role: role, runtime: participant.fetch("runtime"),
144
- adapter: "ordinary", process_pid: binding.fetch("process_identity").fetch("pid"), runtime_binding: binding)
145
- [identity, with_cwd ? native.process_cwd(identity.process_pid) : nil]
146
- end
147
-
148
- def default_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30
149
- end
150
- end
151
- end
152
- end
@@ -1,232 +0,0 @@
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
@@ -1,94 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "yaml"
4
- require_relative "../errors"
5
- require_relative "service_policy"
6
- require_relative "grant_resolver"
7
-
8
- module Ace
9
- module Lab
10
- module Molecules
11
- # Project-scoped, explicitly filed receiver configuration (spec
12
- # 8x3.t.xz9.4 execution clauses). The host owns the file, its
13
- # placement, its credentials and its deployment; this loader only
14
- # validates the exact closed shape and fails closed on anything else.
15
- # It invents no credential and accepts no caller-selected endpoint:
16
- # every value comes from one explicit deployment-owned YAML file.
17
- class OrdinaryReceiverConfig
18
- KEYS = %w[project receiver_id socket_path receiver_uid principal principal_uid
19
- repo_path policy_path execution_identity].freeze
20
- OPTIONAL_KEYS = %w[socket_gid forge_config_path].freeze
21
- IDENTITY_KEYS = %w[checkout_id worktree_id branch].freeze
22
- ID = ServicePolicy::ID
23
-
24
- def self.load(path)
25
- raise Ace::Lab::InvalidConfigurationError, "receiver config path is required" unless path.is_a?(String) && !path.empty?
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)
29
- new(document, source: path)
30
- rescue Errno::ENOENT, Errno::EACCES, Psych::Exception
31
- raise Ace::Lab::InvalidConfigurationError, "receiver configuration is unreadable: #{path}"
32
- end
33
-
34
- attr_reader :project, :receiver_id, :socket_path, :receiver_uid, :principal,
35
- :principal_uid, :repo_path, :policy_path, :execution_identity, :source, :socket_gid, :forge_config_path
36
-
37
- def initialize(document, source: nil)
38
- unless document.is_a?(Hash) && (document.keys - KEYS - OPTIONAL_KEYS).empty? &&
39
- (KEYS - document.keys).empty?
40
- raise Ace::Lab::InvalidConfigurationError,
41
- "receiver configuration requires #{KEYS.join(', ')}; optional: #{OPTIONAL_KEYS.join(', ')}"
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")
46
- @source = source
47
- @project = string!(document.fetch("project"), "project")
48
- @receiver_id = string!(document.fetch("receiver_id"), "receiver_id")
49
- @principal = string!(document.fetch("principal"), "principal")
50
- @socket_path = absolute!(document.fetch("socket_path"), "socket_path")
51
- @repo_path = absolute!(document.fetch("repo_path"), "repo_path")
52
- @policy_path = absolute!(document.fetch("policy_path"), "policy_path")
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?
55
- @principal_uid = uid!(document.fetch("principal_uid"), "principal_uid")
56
- identity = document.fetch("execution_identity")
57
- unless identity.is_a?(Hash) && (identity.keys - IDENTITY_KEYS).empty? &&
58
- IDENTITY_KEYS.all? { |key| identity[key].is_a?(String) && !identity[key].empty? }
59
- raise Ace::Lab::InvalidConfigurationError,
60
- "receiver execution_identity is incomplete"
61
- end
62
- @execution_identity = IDENTITY_KEYS.to_h { |key| [key, identity.fetch(key)] }.freeze
63
- unless @repo_path.start_with?("/") && File.directory?(@repo_path)
64
- raise Ace::Lab::InvalidConfigurationError, "receiver repo_path is not a directory"
65
- end
66
- freeze
67
- end
68
-
69
- private
70
-
71
- def string!(value, name)
72
- unless value.is_a?(String) && !value.empty? && value.length <= 256 && value.match?(ID)
73
- raise Ace::Lab::InvalidConfigurationError, "receiver #{name} is invalid"
74
- end
75
- value
76
- end
77
-
78
- def absolute!(value, name)
79
- unless value.is_a?(String) && value.start_with?("/") && value.length <= 1024
80
- raise Ace::Lab::InvalidConfigurationError, "receiver #{name} must be an absolute path"
81
- end
82
- value
83
- end
84
-
85
- def uid!(value, name)
86
- unless value.is_a?(Integer) && value >= 0
87
- raise Ace::Lab::InvalidConfigurationError, "receiver #{name} must be a non-negative integer"
88
- end
89
- value
90
- end
91
- end
92
- end
93
- end
94
- end