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,158 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "yaml"
5
- require "digest"
6
- require "ace/assign/atoms/evidence_digest"
7
- require_relative "grant_resolver"
8
-
9
- module Ace
10
- module Lab
11
- module Molecules
12
- # One deployment-owned policy, independent of repository preferences.
13
- # The selected grant covers the WHOLE service combination and exact argv.
14
- class CommandPolicy
15
- PATH = "/etc/lab/ace-lab/project-commands.yml"
16
- ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}\z/
17
- ENV_KEY = /\A[A-Z][A-Z0-9_]{0,63}\z/
18
- RESERVED = %w[HOME USER LOGNAME SHELL PATH RUBYOPT RUBYLIB BUNDLE_GEMFILE GEM_HOME GEM_PATH BASH_ENV ENV PYTHONPATH PYTHONHOME PERL5LIB LD_PRELOAD LD_LIBRARY_PATH].freeze
19
- attr_reader :project, :launcher
20
-
21
- def self.load(project:)
22
- bytes = GrantResolver.read_verified(PATH)
23
- raise InvalidConfigurationError, "project commands are not installed" unless bytes && bytes.bytesize <= 65_536
24
- new(YAML.safe_load(bytes, aliases: false), project: project)
25
- rescue Psych::Exception
26
- raise InvalidConfigurationError, "invalid project command policy"
27
- end
28
-
29
- def initialize(document, project:)
30
- strict!(document, %w[version projects])
31
- invalid! unless document["version"] == 1 && document["projects"].is_a?(Hash) &&
32
- document["projects"].keys.all? { |id| id.is_a?(String) && ID.match?(id) } && project.is_a?(String) && ID.match?(project)
33
- @project = project
34
- @document = JSON.parse(JSON.generate(document))
35
- @entry = @document.fetch("projects").fetch(project) { invalid! }
36
- strict!(@entry, %w[launcher default_executor default_services executors services commands grants])
37
- @launcher = @entry.fetch("launcher")
38
- strict!(@launcher, %w[socket_path identity_path])
39
- invalid! unless @launcher.values.all? { |value| absolute?(value) }
40
- %w[executors services commands].each do |key|
41
- value = @entry.fetch(key)
42
- invalid! unless value.is_a?(Hash) && value.size <= 64 && value.keys.all? { |id| id.is_a?(String) && ID.match?(id) }
43
- end
44
- @entry.fetch("executors").each_value do |value|
45
- strict!(value, %w[uid gid groups home cwd path environment])
46
- invalid! unless %w[uid gid].all? { |key| uid?(value[key]) } &&
47
- value["groups"].is_a?(Array) && value["groups"].all? { |gid| uid?(gid) } &&
48
- value["groups"].uniq == value["groups"] &&
49
- %w[home cwd].all? { |key| absolute?(value[key]) } &&
50
- value["path"].is_a?(Array) && !value["path"].empty? && value["path"].all? { |item| absolute?(item) }
51
- environment!(value.fetch("environment"))
52
- end
53
- @entry.fetch("services").each_value do |value|
54
- strict!(value, %w[environment secret_references])
55
- environment!(value.fetch("environment"))
56
- references = value.fetch("secret_references")
57
- invalid! unless references.is_a?(Hash) && references.all? { |key, path| env_key?(key) && absolute?(path) }
58
- end
59
- @entry.fetch("commands").each_value do |value|
60
- strict!(value, %w[argv executable_sha256 source purpose])
61
- invalid! unless %w[command test].include?(value["purpose"])
62
- argv!(value.fetch("argv"))
63
- invalid! unless sha?(value.fetch("executable_sha256"))
64
- source = value.fetch("source")
65
- strict!(source, %w[head tree])
66
- invalid! unless source.values.all? { |sha| sha.nil? || (sha.is_a?(String) && sha.match?(/\A[0-9a-f]{40}\z/)) } &&
67
- source.values.all?(&:nil?) == source.values.any?(&:nil?)
68
- invalid! if value["purpose"] == "test" && source.values.any?(&:nil?)
69
- end
70
- invalid! unless @entry.fetch("executors").key?(@entry["default_executor"])
71
- services!(@entry.fetch("default_services"))
72
- invalid! unless @entry["grants"].is_a?(Array) && @entry["grants"].size <= 256
73
- @entry["grants"].each do |grant|
74
- strict!(grant, %w[kind uid command executor services])
75
- invalid! unless %w[operator agent].include?(grant["kind"]) && uid?(grant["uid"]) &&
76
- @entry.fetch("commands").key?(grant["command"]) && @entry.fetch("executors").key?(grant["executor"])
77
- services!(grant.fetch("services"))
78
- end
79
- @fingerprint = Ace::Assign::Atoms::EvidenceDigest.digest(@entry)
80
- freeze_value(@document)
81
- end
82
-
83
- def reader!(uid)
84
- deny! unless @entry.fetch("grants").any? { |grant| grant.fetch("uid") == uid }
85
- true
86
- end
87
-
88
- def select(selection:, peer:)
89
- strict!(selection, %w[argv executor services pane])
90
- argv!(selection.fetch("argv"))
91
- executor_id = selection["executor"] || @entry.fetch("default_executor")
92
- executor = @entry.fetch("executors")[executor_id]
93
- deny! unless executor
94
- requested = selection["services"].nil? ? @entry.fetch("default_services") : selection["services"]
95
- services!(requested)
96
- selected = requested.uniq.sort
97
- matches = @entry.fetch("commands").select { |_name, value| value.fetch("argv") == selection["argv"] }
98
- deny! unless matches.size == 1
99
- command_id, command = matches.first
100
- kind = selection["pane"].nil? ? "operator" : "agent"
101
- deny! unless @entry.fetch("grants").any? { |grant|
102
- grant.values_at("kind", "uid", "command", "executor") == [kind, peer.fetch("uid"), command_id, executor_id] &&
103
- grant.fetch("services").sort == selected }
104
- environment = executor.fetch("environment").dup
105
- references = {}
106
- selected.each do |id|
107
- service = @entry.fetch("services").fetch(id)
108
- additions = service.fetch("environment")
109
- secrets = service.fetch("secret_references")
110
- deny! unless (environment.keys & (additions.keys + secrets.keys)).empty? &&
111
- (references.keys & (additions.keys + secrets.keys)).empty? && (additions.keys & secrets.keys).empty?
112
- environment.merge!(additions)
113
- references.merge!(secrets)
114
- end
115
- definition = {"policy_sha256" => @fingerprint, "project_id" => @project, "command_id" => command_id,
116
- "argv" => command.fetch("argv"), "executable_sha256" => command.fetch("executable_sha256"),
117
- "source" => command.fetch("source"), "purpose" => command.fetch("purpose"), "executor_id" => executor_id,
118
- "executor" => executor.merge("environment" => environment, "groups" => executor.fetch("groups").sort), "services" => selected,
119
- "secret_references" => references, "requester" => peer, "requester_kind" => kind,
120
- "selection" => selection}
121
- invalid! if JSON.generate(definition).bytesize > 8192
122
- definition
123
- end
124
-
125
- private
126
-
127
- def strict!(value, keys)
128
- invalid! unless value.is_a?(Hash) && value.keys.sort == keys.sort
129
- end
130
- def argv!(value)
131
- invalid! unless value.is_a?(Array) && value.size.between?(1, 128) &&
132
- value.all? { |item| item.is_a?(String) && item.bytesize <= 4096 && !item.include?("\0") } &&
133
- !value.first.empty? && value.sum(&:bytesize) <= 8192
134
- end
135
- def services!(value)
136
- invalid! unless value.is_a?(Array) && value.size <= 64 && value.all? { |id| @entry.fetch("services").key?(id) }
137
- end
138
- def environment!(value)
139
- invalid! unless value.is_a?(Hash) && value.size <= 64 && value.all? { |key, item|
140
- env_key?(key) && item.is_a?(String) && item.bytesize <= 4096 && !item.include?("\0") }
141
- end
142
- def env_key?(key) = key.is_a?(String) && ENV_KEY.match?(key) && !RESERVED.include?(key) && !key.start_with?("GIT_CONFIG_", "BUNDLE_", "LD_", "DYLD_")
143
- def absolute?(value) = value.is_a?(String) && value.start_with?("/") && !value.include?("\0") && File.expand_path(value) == value
144
- def uid?(value) = value.is_a?(Integer) && value.between?(1, 2**31 - 1)
145
- def sha?(value) = value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/)
146
- def invalid! = raise(InvalidConfigurationError, "invalid project command policy")
147
- def deny! = raise(SecurityError, "project command selection is not authorized")
148
- def freeze_value(value)
149
- case value
150
- when Hash then value.each { |key, child| key.freeze; freeze_value(child) }
151
- when Array then value.each { |child| freeze_value(child) }
152
- end
153
- value.freeze
154
- end
155
- end
156
- end
157
- end
158
- end
@@ -1,63 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "etc"
4
-
5
- module Ace
6
- module Lab
7
- module Molecules
8
- # The lab-side HITL delivery authorization (spec 8wq.t.34i): which
9
- # authenticated transport may submit correlated answers for a
10
- # Captain, derived from the SAME trusted deployment document that
11
- # carries the service principals — read through the grant
12
- # resolver's verified traversal, never from the cascade. The peer
13
- # uid is a KERNEL fact resolved by the HITL boundary; this policy
14
- # never trusts a claimed name.
15
- class HitlAuthorizer
16
- # @param principals [Hash] authorization.principals from the
17
- # trusted document (uid-string or username keys, per-project)
18
- # @param transport_uids [Array<Integer>] uids authorized to act
19
- # as the configured transport
20
- # @param service_uid [Integer, nil] the trusted HITL service uid
21
- def initialize(principals: {}, transport_uids: [], service_uid: nil)
22
- @principals = principals || {}
23
- @transport_uids = Array(transport_uids)
24
- @service_uid = service_uid
25
- end
26
-
27
- # Build from the trusted deployment document at a fixed path
28
- # (same file the service policy resolves principals from).
29
- # @return [HitlAuthorizer]
30
- def self.from_trusted_document(path, loader = GrantResolver)
31
- document = loader.trusted_document(path)
32
- hitl = document["hitl"].is_a?(Hash) ? document["hitl"] : {}
33
- principals = document.dig("authorization", "principals")
34
- new(
35
- principals: principals.is_a?(Hash) ? principals : {},
36
- transport_uids: Array(hitl["transport_uids"]),
37
- service_uid: hitl["service_uid"]
38
- )
39
- end
40
-
41
- # @return [Integer, nil] the trusted HITL service identity
42
- def service_uid
43
- @service_uid.is_a?(Integer) ? @service_uid : nil
44
- end
45
-
46
- # The transport may submit a correlated answer from an authorized
47
- # Captain when its KERNEL-VERIFIED uid is a configured transport
48
- # AND the transport's principal is visible for the request's
49
- # project. Unknown identity is an error-shaped refusal, never
50
- # permission.
51
- def delivery_authorized?(uid:, project_id:)
52
- return false unless @transport_uids.include?(uid)
53
-
54
- policy = @principals[uid.to_s]
55
- return false unless policy.is_a?(Hash)
56
-
57
- projects = Array(policy["projects"]).map(&:to_s)
58
- projects.empty? || projects.include?(project_id.to_s)
59
- end
60
- end
61
- end
62
- end
63
- end
@@ -1,131 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "time"
4
- require_relative "service_policy"
5
-
6
- module Ace
7
- module Lab
8
- module Molecules
9
- # The scoped ordinary-merge decision/admission resolver (spec 8x3.t.xz9.4,
10
- # execution clauses). The deployment-owned YAML authorization file remains
11
- # the sole source of truth; this resolver only makes the exact root
12
- # authorizations natively executable inside the receiver: one per-request
13
- # authorization selector resolves to a typed decision within the closed
14
- # refusal-code family — or it refuses. Scoping rules:
15
- #
16
- # - only `operation: merge` decisions of the ordinary shape resolve;
17
- # anything else (missing reference, shape mismatch, non-merge
18
- # operation, any binding mismatch, expiry) is a typed
19
- # `ordinary_merge_authorization_invalid` decision;
20
- # - exact binding match on every closed key; there are NO wildcards,
21
- # defaults, prefix grants or broad grants of any kind — a decision
22
- # authorizes exactly one bound request and nothing else;
23
- # - expiry bounds the decision itself.
24
- #
25
- # Per-request refresh (spec 8x3.t.xz9.5 integration handoff): a
26
- # long-lived receiver would otherwise decide every sequential candidate
27
- # against its boot-time snapshot of the authorization document. When a
28
- # trusted path is installed, every decision re-reads the document
29
- # through the trusted loader and resolves against the FRESH grants —
30
- # a root-revoked or expired decision fails closed at the next request
31
- # without any receiver restart. The refresh is scoped: it reloads and
32
- # re-validates the exact-request grant only; it never broadens
33
- # authorization beyond the resolved reference.
34
- class OrdinaryAdmissionResolver
35
- BINDING_KEYS = ServicePolicy::ORDINARY_BINDING_KEYS.freeze
36
- DECISION_KEYS = ServicePolicy::ORDINARY_AUTHORIZATION_KEYS.freeze
37
- OPERATION = "merge"
38
-
39
- Decision = Struct.new(:authorized?, :decision, :code, :message, keyword_init: true)
40
-
41
- # @param policy_document [Hash] boot-time trusted document (the
42
- # configuration digest is derived from it once, upstream)
43
- # @param path [String, nil] trusted policy path to re-read per decision
44
- # @param loader [Proc, nil] trusted reader (default: GrantResolver);
45
- # the only override point, for non-privileged test documents
46
- def initialize(policy_document, path: nil, loader: nil, proposal_resolver: nil, caller_uid: nil)
47
- unless policy_document.is_a?(Hash) && policy_document["authorizations"].is_a?(Hash)
48
- raise Ace::Lab::InvalidConfigurationError,
49
- "trusted policy document must contain an authorizations mapping"
50
- end
51
- @authorizations = policy_document.fetch("authorizations")
52
- @configuration_digest = ServicePolicy.configuration_digest(policy_document)
53
- @path = path
54
- @proposal_resolver = proposal_resolver
55
- @caller_uid = caller_uid
56
- @loader = loader || ->(trusted_path) { GrantResolver.trusted_document(trusted_path) }
57
- freeze
58
- end
59
-
60
- # Resolve one authorization selector against the exact ordinary merge
61
- # request binding. Returns a typed Decision; `authorized?` is true only
62
- # for an exact, unexpired, ordinary-merge-shaped match.
63
- def decide(reference, binding)
64
- authorizations = current_authorizations
65
- unless reference.is_a?(String) && reference.match?(ServicePolicy::ID)
66
- return refuse("invalid authorization reference")
67
- end
68
- unless binding.is_a?(Hash) && binding.fetch("operation", nil) == OPERATION
69
- return refuse("only the ordinary merge operation is resolvable")
70
- end
71
- if reference.start_with?("proposal-")
72
- return refuse("canonical proposal authority is unavailable") unless @proposal_resolver && @caller_uid.is_a?(Integer)
73
- begin
74
- decision = @proposal_resolver.call(reference, binding.slice(
75
- "operation", "project_id", "assignment_id", "attempt_id", "input_digest", "candidate_head").merge(
76
- "target" => {"resource" => binding.fetch("pr_url"), "artifact_digest" => nil},
77
- "caller_uid" => @caller_uid))
78
- return Decision.new(authorized?: true, decision: decision, code: nil, message: nil)
79
- rescue Ace::Assign::Error
80
- return refuse("canonical proposal does not authorize this exact ordinary merge")
81
- end
82
- end
83
- decision = authorizations[reference]
84
- return refuse("authorization reference is unresolved") unless decision.is_a?(Hash)
85
- unless DECISION_KEYS.all? { |key| decision.key?(key) }
86
- return refuse("authorization reference is not an ordinary merge decision")
87
- end
88
- unless decision.fetch("operation") == OPERATION
89
- return refuse("authorization does not cover the ordinary merge operation")
90
- end
91
- mismatch = BINDING_KEYS.find { |key| decision.fetch(key) != binding.fetch(key, nil) }
92
- return refuse("authorization does not match the exact ordinary merge request") if mismatch
93
- begin
94
- expired = Time.iso8601(decision.fetch("expires_at").to_s) <= Time.now.utc
95
- rescue ArgumentError
96
- return refuse("authorization decision has an invalid expiry")
97
- end
98
- return refuse("authorization has expired") if expired
99
- Decision.new(authorized?: true, decision: decision, code: nil, message: nil)
100
- end
101
-
102
- private
103
-
104
- # The authorizations mapping the CURRENT decision resolves against:
105
- # the boot snapshot when no trusted path is installed, otherwise the
106
- # freshly re-read document. A failed trusted re-read fails closed
107
- # (InvalidConfigurationError — the unavailable family), never falls
108
- # back to the stale snapshot.
109
- def current_authorizations
110
- return @authorizations unless @path
111
- document = @loader.call(@path)
112
- unless document.is_a?(Hash) && document["authorizations"].is_a?(Hash)
113
- raise Ace::Lab::InvalidConfigurationError,
114
- "invalid lab configuration: trusted authorization refresh at #{@path} " \
115
- "did not yield an authorizations mapping"
116
- end
117
- unless ServicePolicy.configuration_digest(document) == @configuration_digest
118
- raise Ace::Lab::InvalidConfigurationError,
119
- "ordinary receiver configuration changed; restart with the installed configuration"
120
- end
121
- document.fetch("authorizations")
122
- end
123
-
124
- def refuse(message)
125
- Decision.new(authorized?: false, decision: nil,
126
- code: "ordinary_merge_authorization_invalid", message: message)
127
- end
128
- end
129
- end
130
- end
131
- end
@@ -1,262 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "socket"
5
- require_relative "../errors"
6
- require_relative "ordinary_socket"
7
-
8
- module Ace
9
- module Lab
10
- module Molecules
11
- # The ordinary receiver's serve-side transport (spec 8x3.t.xz9.4 serve
12
- # entrypoint). Binds the configured unix socket and answers the exact
13
- # framed protocol the OrdinarySocket client speaks: one bounded JSON
14
- # document each way behind the shared handshake line. Authentication is
15
- # MUTUAL through OS peer credentials: every connection's peer UID must equal
16
- # the configured producer principal UID, and the listener itself runs
17
- # as the configured receiver UID — the identity the client verifies on
18
- # its side. Every identity, framing, deadline or parse failure is
19
- # fail-closed: the peer receives the closed refusal envelope (or the
20
- # connection closes) and nothing is ever dispatched. This is not an
21
- # HTTP transport and there is no cross-VM fallback.
22
- class OrdinaryListener
23
- HANDSHAKE = OrdinarySocket::HANDSHAKE
24
- LIMIT = OrdinarySocket::LIMIT
25
- DEADLINE_SECONDS = 30
26
-
27
- def initialize(socket_path:, peer_uid:, handler:, socket_gid: nil, lock_path: nil, logger: nil)
28
- unless socket_path.is_a?(String) && socket_path.start_with?("/") &&
29
- peer_uid.is_a?(Integer) && peer_uid >= 0 && handler.respond_to?(:call)
30
- raise ArgumentError, "ordinary listener needs an absolute socket path, a peer uid and a handler"
31
- end
32
- unless socket_gid.nil? || (socket_gid.is_a?(Integer) && socket_gid >= 0)
33
- raise ArgumentError, "ordinary listener socket_gid must be a non-negative integer"
34
- end
35
- @socket_gid = socket_gid
36
- @socket_path = socket_path
37
- @peer_uid = peer_uid
38
- @handler = handler
39
- @lock_path = lock_path || "#{@socket_path}.lock"
40
- @logger = logger
41
- @stopping = false
42
- end
43
-
44
- # Serve until #stop. Excludes a second listener through one owned
45
- # lock file, removes only its own stale endpoint, and always removes
46
- # the endpoint it created.
47
- def serve(on_ready: nil)
48
- acquire_lock!
49
- remove_stale_endpoint!
50
- @listener = UNIXServer.new(@socket_path)
51
- @endpoint_identity = File.stat(@socket_path)
52
- File.chown(nil, @socket_gid, @socket_path) if @socket_gid
53
- File.chmod(@socket_gid ? 0o660 : 0o600, @socket_path)
54
- on_ready.call if on_ready
55
- @handler.recover_pending! if @handler.respond_to?(:recover_pending!)
56
- until @stopping
57
- socket = @listener.accept
58
- serve_connection(socket)
59
- end
60
- rescue IOError, Errno::EBADF
61
- raise unless @stopping
62
- ensure
63
- @listener&.close
64
- remove_owned_endpoint!
65
- @lock&.flock(File::LOCK_UN)
66
- @lock&.close
67
- end
68
-
69
- # The existing listener participates in the Assign owner's lifetime.
70
- def run(on_ready:)
71
- serve(on_ready: on_ready)
72
- end
73
-
74
- def stop
75
- @stopping = true
76
- @listener&.close
77
- rescue IOError
78
- nil
79
- end
80
-
81
- # One bounded connection: verify the peer identity, read one framed
82
- # request, answer one framed envelope, close. Fail-closed on every
83
- # violation; a dispatch never happens from an unverified frame.
84
- def serve_connection(socket)
85
- deadline = Time.now.utc + DEADLINE_SECONDS
86
- begin
87
- verify_peer!(socket)
88
- submission = read_request!(socket, deadline)
89
- response = @handler.call(submission)
90
- write_response_safely(socket, response, deadline)
91
- if response.is_a?(Hash) && response["status"] == "ok" && @handler.respond_to?(:execute)
92
- begin
93
- # Admission is durable even if the acknowledgment was lost; the
94
- # execution lifetime belongs to the canonical record, not
95
- # the short connection. A lost lifecycle stays pending or
96
- # uncertain and reconciles by the original reference.
97
- @handler.execute(response.dig("data", "request_id"))
98
- rescue StandardError => error
99
- log_failure("effect", error)
100
- end
101
- end
102
- rescue OrdinaryRefused => e
103
- write_response_safely(socket, e.envelope, deadline)
104
- rescue OrdinaryListenerError, IOError, SystemCallError => error
105
- # Fail closed without a fabricated envelope; the client classifies
106
- # a lost/unreadable response as ordinary_merge_unavailable.
107
- log_failure("connection", error)
108
- rescue StandardError => error
109
- log_failure("request", error)
110
- write_response_safely(socket, refusal_envelope("ordinary_merge_unavailable",
111
- "receiver failed to handle the request"), deadline)
112
- ensure
113
- begin
114
- socket.close
115
- rescue IOError
116
- nil
117
- end
118
- end
119
- end
120
-
121
- class OrdinaryListenerError < ::StandardError; end
122
-
123
- # A verified-connection refusal that still carries a closed envelope.
124
- class OrdinaryRefused < OrdinaryListenerError
125
- attr_reader :envelope
126
-
127
- def initialize(envelope)
128
- @envelope = envelope
129
- super(envelope.dig("error", "message").to_s)
130
- end
131
- end
132
-
133
- private
134
-
135
- def write_response_safely(socket, response, deadline)
136
- write_response!(socket, response, deadline)
137
- rescue OrdinaryListenerError, IOError, SystemCallError => error
138
- log_failure("response", error)
139
- end
140
-
141
- def log_failure(phase, error)
142
- # Arbitrary exception messages may contain provider credentials or
143
- # request bodies. Journal references remain the source of effect state.
144
- @logger&.call("ordinary receiver #{phase} failed (#{error.class.name})")
145
- end
146
-
147
- # Mutual half of OS peer authentication: the connecting process must run as the
148
- # configured producer principal UID. Anything else is refused before
149
- # one byte of request is read.
150
- def verify_peer!(socket)
151
- peer_uid, = socket.getpeereid
152
- return if peer_uid == @peer_uid
153
- raise OrdinaryRefused, refusal_envelope("ordinary_merge_unauthenticated",
154
- "connecting principal is not the configured ordinary overseer")
155
- rescue SystemCallError, IOError, SocketError, NotImplementedError
156
- raise OrdinaryListenerError, "peer credentials are unavailable"
157
- end
158
-
159
- def read_request!(socket, deadline)
160
- header = read_line!(socket, deadline)
161
- match = /\A#{Regexp.escape(HANDSHAKE)} (\d{1,5})\n\z/o.match(header)
162
- raise OrdinaryListenerError, "request header is invalid" unless match
163
- length = Integer(match[1])
164
- raise OrdinaryListenerError, "request exceeds the wire bound" if length > LIMIT
165
- payload = read_exact!(socket, length, deadline)
166
- JSON.parse(payload, allow_duplicate_key: false, create_additions: false, max_nesting: 16)
167
- rescue JSON::ParserError
168
- raise OrdinaryRefused, refusal_envelope("ordinary_merge_invalid_request",
169
- "request document is not valid JSON")
170
- end
171
-
172
- def write_response!(socket, response, deadline)
173
- payload = JSON.generate(response)
174
- raise OrdinaryListenerError, "response exceeds the wire bound" if payload.bytesize > LIMIT
175
- frame = "#{HANDSHAKE} #{payload.bytesize}\n#{payload}"
176
- written = 0
177
- while written < frame.bytesize
178
- raise OrdinaryListenerError, "response write deadline expired" if Time.now.utc >= deadline
179
- begin
180
- written += socket.write_nonblock(frame.byteslice(written..))
181
- rescue IO::WaitWritable
182
- socket.wait_writable(remaining!(deadline)) or
183
- raise OrdinaryListenerError, "response write deadline expired"
184
- end
185
- end
186
- rescue JSON::GeneratorError
187
- raise OrdinaryListenerError, "response is not serializable"
188
- end
189
-
190
- def refusal_envelope(code, message)
191
- {"status" => "error", "error" => {
192
- "schema" => "ace.lab.service.ordinary-merge-refusal/v1",
193
- "code" => code,
194
- "message" => message.to_s.scrub.gsub(/\s+/, " ").strip[0, 512],
195
- "request_id" => "unknown",
196
- "input_digest" => nil
197
- }}
198
- end
199
-
200
- def read_line!(socket, deadline)
201
- line = +""
202
- until line.end_with?("\n")
203
- raise OrdinaryListenerError, "request header is unbounded" if line.bytesize > 128
204
- line << read_exact!(socket, 1, deadline)
205
- end
206
- line
207
- end
208
-
209
- def read_exact!(socket, length, deadline)
210
- buffer = +""
211
- while buffer.bytesize < length
212
- raise OrdinaryListenerError, "request read deadline expired" if Time.now.utc >= deadline
213
- begin
214
- chunk = socket.read_nonblock(length - buffer.bytesize)
215
- raise OrdinaryListenerError, "peer closed the connection" if chunk.nil?
216
- buffer << chunk
217
- rescue IO::WaitReadable
218
- socket.wait_readable(remaining!(deadline)) or
219
- raise OrdinaryListenerError, "request read deadline expired"
220
- end
221
- end
222
- buffer
223
- end
224
-
225
- def remaining!(deadline)
226
- left = deadline - Time.now.utc
227
- raise OrdinaryListenerError, "connection deadline expired" if left <= 0
228
- left
229
- end
230
-
231
- def acquire_lock!
232
- @lock = File.open(@lock_path, File::RDWR | File::CREAT | File::NOFOLLOW, 0o600)
233
- stat = @lock.stat
234
- unless stat.file? && stat.uid == Process.uid && stat.nlink == 1 && (stat.mode & 0o7777) == 0o600
235
- raise Ace::Lab::InvalidConfigurationError, "receiver listener lock differs"
236
- end
237
- unless @lock.flock(File::LOCK_EX | File::LOCK_NB)
238
- raise Ace::Lab::InvalidConfigurationError, "receiver listener already active"
239
- end
240
- end
241
-
242
- def remove_stale_endpoint!
243
- stat = File.lstat(@socket_path)
244
- unless stat.socket? && stat.uid == Process.uid
245
- raise Ace::Lab::InvalidConfigurationError, "receiver endpoint differs"
246
- end
247
- File.unlink(@socket_path)
248
- rescue Errno::ENOENT
249
- nil
250
- end
251
-
252
- def remove_owned_endpoint!
253
- return unless @endpoint_identity
254
- stat = File.stat(@socket_path)
255
- File.unlink(@socket_path) if stat.socket? && stat.ino == @endpoint_identity.ino
256
- rescue Errno::ENOENT
257
- nil
258
- end
259
- end
260
- end
261
- end
262
- end
@@ -1,45 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "ordinary_worktree_read"
4
-
5
- module Ace
6
- module Lab
7
- module Molecules
8
- # Local checks are bounded read-only observations. Only the forge provider
9
- # executes merge; moving a local ref can never prove a delivered merge.
10
- class OrdinaryMergeRead
11
- STRATEGY = "ff"
12
-
13
- def initialize(repo_path:, base_ref:, root: repo_path, common_dir: File.join(repo_path, ".git"),
14
- deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30)
15
- unless repo_path.is_a?(String) && repo_path.start_with?("/") &&
16
- base_ref.is_a?(String) && base_ref.start_with?("refs/")
17
- raise ArgumentError, "ordinary merge read needs an absolute repository and full base ref"
18
- end
19
- @repo_path, @base_ref = repo_path, base_ref
20
- @reader = OrdinaryWorktreeRead.new(root: root, common_dir: common_dir, deadline: deadline)
21
- end
22
-
23
- def base_head
24
- @reader.ref_head(@repo_path, @base_ref)
25
- rescue Ace::Assign::Error => error
26
- raise GitUnavailable, error.message
27
- end
28
-
29
- def candidate_commit?(head)
30
- @reader.commit?(@repo_path, head)
31
- rescue Ace::Assign::Error => error
32
- raise GitUnavailable, error.message
33
- end
34
-
35
- def fast_forwardable?(head)
36
- @reader.ancestor?(@repo_path, base_head, head)
37
- rescue Ace::Assign::Error => error
38
- raise GitUnavailable, error.message
39
- end
40
-
41
- class GitUnavailable < StandardError; end
42
- end
43
- end
44
- end
45
- end