ace-lab 0.8.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +363 -92
  3. data/README.md +66 -100
  4. data/docs/usage.md +529 -404
  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 -13
  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 +49 -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 -102
  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 -45
  40. data/lib/ace/lab/cli/commands/service.rb +0 -240
  41. data/lib/ace/lab/models/service_receipt.rb +0 -18
  42. data/lib/ace/lab/molecules/hitl_authorizer.rb +0 -63
  43. data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +0 -131
  44. data/lib/ace/lab/molecules/ordinary_listener.rb +0 -262
  45. data/lib/ace/lab/molecules/ordinary_merge_read.rb +0 -45
  46. data/lib/ace/lab/molecules/ordinary_native_read.rb +0 -106
  47. data/lib/ace/lab/molecules/ordinary_owner_admission.rb +0 -139
  48. data/lib/ace/lab/molecules/ordinary_owner_config.rb +0 -232
  49. data/lib/ace/lab/molecules/ordinary_receiver_config.rb +0 -94
  50. data/lib/ace/lab/molecules/ordinary_socket.rb +0 -126
  51. data/lib/ace/lab/molecules/ordinary_worktree_read.rb +0 -216
  52. data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +0 -163
  53. data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +0 -165
  54. data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +0 -239
  55. data/lib/ace/lab/molecules/protected_publication_publisher.rb +0 -110
  56. data/lib/ace/lab/molecules/protected_service_handler.rb +0 -43
  57. data/lib/ace/lab/molecules/protected_service_policy.rb +0 -156
  58. data/lib/ace/lab/molecules/service_executor.rb +0 -99
  59. data/lib/ace/lab/molecules/service_policy.rb +0 -289
  60. data/lib/ace/lab/organisms/authority_composition.rb +0 -87
  61. data/lib/ace/lab/organisms/ordinary_assignment_client.rb +0 -149
  62. data/lib/ace/lab/organisms/ordinary_authority_composition.rb +0 -123
  63. data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +0 -688
  64. data/lib/ace/lab/organisms/ordinary_merge_request.rb +0 -456
  65. data/lib/ace/lab/organisms/protected_cleanup_owner.rb +0 -361
  66. data/lib/ace/lab/organisms/protected_service_client.rb +0 -129
  67. data/lib/ace/lab/organisms/protected_service_listener.rb +0 -181
  68. data/lib/ace/lab/organisms/protected_service_publication.rb +0 -268
  69. data/lib/ace/lab/organisms/protected_service_receiver.rb +0 -267
  70. data/lib/ace/lab/organisms/protected_service_recovery.rb +0 -185
  71. data/lib/ace/lab/organisms/protected_service_request.rb +0 -128
  72. data/lib/ace/lab/organisms/protected_service_worker.rb +0 -89
  73. data/lib/ace/lab/organisms/service_request_service.rb +0 -249
@@ -1,163 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "ace/assign/authority/service_evidence"
4
- require_relative "protected_cleanup_owner_client"
5
- require "ace/assign/authority/endcap"
6
- require "ace/assign/authority/launch_lifecycle"
7
-
8
- module Ace
9
- module Lab
10
- module Molecules
11
- # Only the fixed listener supplies the installed journal snapshot and its
12
- # independently observed self binding. A frame selects neither owner.
13
- class ProtectedCleanupOwnerAdmission
14
- FIELDS = %w[schema kind request_id input_digest claim_binding request_event_digest
15
- dispatch_event_digest input operation_owner_binding_digest].freeze
16
- INSPECT_FIELDS = (FIELDS + %w[challenge_ref]).freeze
17
-
18
- def initialize(deployment:, authority_id:, mapping_id:, service_id:, kernel:, preview_policy: nil)
19
- @deployment, @authority_id, @kernel = deployment, authority_id, kernel
20
- @mapping_id, @service_id = mapping_id, service_id
21
- @preview_policy = preview_policy
22
- end
23
-
24
- def identity_reader!(peer)
25
- authority = @deployment.authority(@authority_id)
26
- if peer.slice("uid", "gid", "groups") == authority.slice("uid", "gid", "groups")
27
- @kernel.live!(peer)
28
- return true
29
- end
30
- receiver!(peer)
31
- end
32
-
33
- def connection_group!(gid)
34
- authority = @deployment.authority(@authority_id)
35
- map = @deployment.mapping(@mapping_id)
36
- project = @deployment.project(map.fetch("project_id"))
37
- receiver = project.fetch("service_receivers").fetch(@service_id)
38
- credentials = project.fetch("peer_credentials").fetch(receiver.fetch("executor_uid").to_s)
39
- unless gid.is_a?(Integer) && gid.positive? && [authority, credentials].all? { |principal| principal.fetch("gid") == gid || principal.fetch("groups").include?(gid) }
40
- raise SecurityError, "cleanup connection group differs from installed principals"
41
- end
42
- true
43
- end
44
-
45
- def receiver!(peer)
46
- map = @deployment.mapping(@mapping_id)
47
- project = @deployment.project(map.fetch("project_id"))
48
- receiver = project.fetch("service_receivers").fetch(@service_id)
49
- credentials = project.fetch("peer_credentials").fetch(receiver.fetch("executor_uid").to_s)
50
- unless map.fetch("authority_id") == @authority_id && peer.fetch("uid").is_a?(Integer) && peer.fetch("uid").positive? &&
51
- peer.fetch("uid") == receiver.fetch("executor_uid") && peer.fetch("gid") == credentials.fetch("gid") &&
52
- peer.fetch("groups") == credentials.fetch("groups")
53
- raise SecurityError, "cleanup installed receiver differs"
54
- end
55
- @kernel.live!(peer)
56
- true
57
- rescue KeyError, TypeError
58
- raise SecurityError, "cleanup installed receiver unavailable"
59
- end
60
-
61
- def preview!(frame:, peer:, operation_owner_binding:, journal:)
62
- receiver!(peer)
63
- Atoms::ProtectedWorkspacePruneInput.object!(frame, %w[schema kind intent maintenance_context])
64
- raise SecurityError, "cleanup preview kind differs" unless frame.values_at("schema", "kind") == [ProtectedCleanupOwnerClient::SCHEMA, "preview"]
65
- intent = Atoms::ProtectedWorkspacePrunePreview.intent!(frame.fetch("intent"))
66
- supplied = Atoms::ProtectedWorkspacePrunePreview.context!(frame.fetch("maintenance_context"))
67
- maintenance = supplied.fetch("maintenance")
68
- map = @deployment.mapping(@mapping_id)
69
- unless intent.fetch("maintenance") == maintenance && maintenance.fetch("mapping_id") == @mapping_id &&
70
- maintenance.fetch("project_id") == map.fetch("project_id") && map.fetch("authority_id") == @authority_id
71
- raise SecurityError, "cleanup preview installed scope differs"
72
- end
73
- raise SecurityError, "cleanup preview policy composition unavailable" unless @preview_policy
74
- launch = Ace::Assign::Authority::LaunchLifecycle.new(deployment: @deployment, kernel: @kernel)
75
- owner = Ace::Assign::Authority::Endcap.new(deployment: @deployment, launch: launch, kernel: @kernel, service_policy: @preview_policy)
76
- params = maintenance.except("project_id").merge("service_id" => @service_id,
77
- "worker_process_binding" => supplied.fetch("caller_process_binding"))
78
- selected = owner.workspace_prune_preview_context_at!(journal: journal, params: params, map: map, peer: peer, role: :executor)
79
- raise SecurityError, "cleanup preview canonical selection changed" unless selected == supplied
80
- Atoms::ProtectedWorkspacePruneInput.freeze_value({"intent" => intent, "maintenance_context" => selected,
81
- "operation_owner_binding" => operation_owner_binding})
82
- rescue KeyError, TypeError, ArgumentError
83
- raise SecurityError, "cleanup preview canonical selection unavailable"
84
- end
85
-
86
- def admit!(frame:, peer:, operation_owner_binding:, journal:)
87
- admit_original!(frame: frame, peer: peer, operation_owner_binding: operation_owner_binding, journal: journal, purpose: :execute)
88
- end
89
-
90
- def inspect!(frame:, peer:, operation_owner_binding:, journal:)
91
- context = admit_original!(frame: frame, peer: peer, operation_owner_binding: operation_owner_binding, journal: journal, purpose: :inspect)
92
- reference = frame.fetch("challenge_ref")
93
- Atoms::ProtectedWorkspacePruneInput.object!(reference, %w[challenge_event_digest])
94
- Atoms::ProtectedWorkspacePruneInput.digest!(reference.fetch("challenge_event_digest"))
95
- challenge = Ace::Assign::Authority::ServiceEvidence.new(journal: journal).challenge!(context.fetch("record"), pending: {commit: context.fetch("commit")})
96
- unless challenge.fetch("digest") == reference.fetch("challenge_event_digest")
97
- raise SecurityError, "cleanup inspection challenge differs"
98
- end
99
- Atoms::ProtectedWorkspacePruneInput.freeze_value(context.merge("challenge" => challenge))
100
- end
101
-
102
- private
103
-
104
- def admit_original!(frame:, peer:, operation_owner_binding:, journal:, purpose:)
105
- receiver!(peer)
106
- Atoms::ProtectedWorkspacePruneInput.object!(frame, purpose == :execute ? FIELDS : INSPECT_FIELDS)
107
- unless frame.values_at("schema", "kind") == [ProtectedCleanupOwnerClient::SCHEMA, purpose.to_s]
108
- raise SecurityError, "cleanup invocation kind differs"
109
- end
110
- Atoms::ProtectedWorkspacePruneInput.token!(frame.fetch("request_id"))
111
- (FIELDS - %w[schema kind request_id input]).each do |key|
112
- Atoms::ProtectedWorkspacePruneInput.digest!(frame.fetch(key))
113
- end
114
- input = Atoms::ProtectedWorkspacePruneInput.parse(JSON.generate(frame.fetch("input")))
115
- unless Atoms::ServiceInput.digest(input) == frame.fetch("input_digest") &&
116
- (purpose == :inspect || digest(operation_owner_binding) == frame.fetch("operation_owner_binding_digest"))
117
- raise SecurityError, "cleanup original input or owner differs"
118
- end
119
- maintenance = input.fetch("maintenance")
120
- map = @deployment.mapping(maintenance.fetch("mapping_id"))
121
- unless maintenance.fetch("mapping_id") == @mapping_id && map.fetch("authority_id") == @authority_id && map.fetch("project_id") == maintenance.fetch("project_id")
122
- raise SecurityError, "cleanup installed scope differs"
123
- end
124
- unless journal.evidence_mode == :protected
125
- raise SecurityError, "cleanup requires the installed protected service evidence owner"
126
- end
127
- commit = journal.ref_value
128
- journal.verify_canonical_prefix!(commit: commit)
129
- record = journal.service_request(frame.fetch("request_id"), commit: commit)
130
- unless record && record.fetch("service_id") == @service_id && record.slice("project_id", "mapping_id", "assignment_id", "attempt_id") == maintenance &&
131
- record.values_at("operation", "dispatch_phase") == ["prune-preserved-workspace", "dispatch_started"] &&
132
- (purpose == :execute ? record["state"] == "uncertain" : %w[uncertain failed].include?(record["state"])) &&
133
- record.slice("request_id", "input_digest", "claim_binding") == frame.slice("request_id", "input_digest", "claim_binding") &&
134
- record.fetch("target") == Atoms::ServiceInput.target(input) &&
135
- digest(record.fetch("operation_owner_binding")) == frame.fetch("operation_owner_binding_digest") &&
136
- (purpose == :inspect || digest(record.fetch("executor_process_binding")) == digest(peer))
137
- raise SecurityError, "cleanup canonical original dispatch differs"
138
- end
139
- Ace::Assign::Authority::ServiceEvidence.new(journal: journal).context(record, pending: {commit: commit})
140
- receiver = @deployment.project(map.fetch("project_id")).fetch("service_receivers").fetch(record.fetch("service_id"))
141
- credentials = @deployment.project(map.fetch("project_id")).fetch("peer_credentials").fetch(receiver.fetch("executor_uid").to_s)
142
- unless peer.fetch("uid") == record.fetch("executor_uid") && peer.fetch("uid") == receiver.fetch("executor_uid") &&
143
- peer.fetch("gid") == credentials.fetch("gid") && peer.fetch("groups") == credentials.fetch("groups")
144
- raise SecurityError, "cleanup original executor differs"
145
- end
146
- @kernel.live!(peer)
147
- proof = Ace::Assign::Authority::ServiceEvidence.new(journal: journal).cleanup_dispatch_context!(
148
- record, commit: commit, selectors: frame.slice("request_event_digest", "dispatch_event_digest"))
149
- if purpose == :execute && proof.fetch("dispatch_record") != record
150
- raise SecurityError, "cleanup original executable record differs"
151
- end
152
- Atoms::ProtectedWorkspacePruneInput.freeze_value(JSON.parse(JSON.generate({"commit" => commit,
153
- "record" => record, "input" => input, "operation_owner_binding" => operation_owner_binding,
154
- "request_event_digest" => frame.fetch("request_event_digest"), "dispatch_event_digest" => frame.fetch("dispatch_event_digest")})))
155
- rescue KeyError, TypeError, ArgumentError
156
- raise SecurityError, "cleanup canonical admission is unavailable"
157
- end
158
-
159
- def digest(value) = Ace::Assign::Atoms::EvidenceDigest.digest(value)
160
- end
161
- end
162
- end
163
- end
@@ -1,165 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "ace/runtime/molecules/protected_socket"
4
- require "ace/assign/authority/transfer_codec"
5
- require "ace/assign/atoms/evidence_digest"
6
- require_relative "../atoms/protected_workspace_prune_input"
7
- require_relative "../atoms/service_input"
8
- require_relative "../atoms/protected_workspace_prune_preview"
9
-
10
- module Ace
11
- module Lab
12
- module Molecules
13
- # Fixed installed endpoint. A fresh execute connection must independently
14
- # match the original canonical binding; identity EOF is never permission.
15
- class ProtectedCleanupOwnerClient
16
- PATH = "/run/lab/protected-workspace-cleanup.sock"
17
- SCHEMA = "lab.protected-workspace-cleanup-control/v1"
18
- LIMIT = 16_384
19
-
20
- def initialize(observer:, scratch_root:, wire: Ace::Runtime::Molecules::ProtectedSocket)
21
- @observer, @wire = observer, wire
22
- @codec = Ace::Assign::Authority::TransferCodec.new(root: scratch_root)
23
- end
24
-
25
- def identity!
26
- deadline = @wire.deadline(5)
27
- connect(deadline) do |socket, binding|
28
- @wire.write(socket, {"schema" => SCHEMA, "kind" => "identity"}, deadline: deadline, limit: LIMIT)
29
- socket.shutdown(Socket::SHUT_WR)
30
- guarded = Ace::Runtime::Molecules::ProtectedSocket::Ingress.new(socket)
31
- reply = @wire.read(guarded, deadline: deadline, limit: LIMIT)
32
- eof!(guarded, deadline)
33
- unless reply.is_a?(Hash) && reply.keys.sort == %w[kind operation_owner_binding schema] &&
34
- reply["schema"] == SCHEMA && reply["kind"] == "identity" &&
35
- digest(reply["operation_owner_binding"]) == digest(binding)
36
- raise SecurityError, "cleanup identity reply differs from observed original owner"
37
- end
38
- binding
39
- end
40
- end
41
-
42
- def preview!(intent:, maintenance_context:, deadline: @wire.deadline(5))
43
- intent = Atoms::ProtectedWorkspacePrunePreview.intent!(JSON.parse(JSON.generate(intent)))
44
- context = Atoms::ProtectedWorkspacePrunePreview.context!(JSON.parse(JSON.generate(maintenance_context)))
45
- raise SecurityError, "preview maintenance differs" unless intent.fetch("maintenance") == context.fetch("maintenance")
46
- connect(deadline) do |socket, _original|
47
- @wire.write(socket, {"schema" => SCHEMA, "kind" => "preview", "intent" => intent,
48
- "maintenance_context" => context}, deadline: deadline, limit: 65_536)
49
- socket.shutdown(Socket::SHUT_WR)
50
- guarded = Ace::Runtime::Molecules::ProtectedSocket::Ingress.new(socket)
51
- result = @wire.read(guarded, deadline: deadline, limit: LIMIT)
52
- eof!(guarded, deadline)
53
- Atoms::ProtectedWorkspacePrunePreview.result!(result, intent: intent, context: context)
54
- end
55
- end
56
-
57
- def execute!(request:, operation_owner_binding:)
58
- request = JSON.parse(JSON.generate(request), create_additions: false, max_nesting: 16)
59
- fields = %w[request_id input_digest claim_binding request_event_digest dispatch_event_digest input]
60
- Atoms::ProtectedWorkspacePruneInput.object!(request, fields)
61
- Atoms::ProtectedWorkspacePruneInput.token!(request.fetch("request_id"))
62
- (fields - %w[request_id input]).each { |key| Atoms::ProtectedWorkspacePruneInput.digest!(request.fetch(key)) }
63
- bytes = JSON.generate(request.fetch("input"))
64
- input = Atoms::ProtectedWorkspacePruneInput.parse(bytes)
65
- unless Atoms::ServiceInput.digest(input) == request.fetch("input_digest")
66
- raise SecurityError, "cleanup execute input differs"
67
- end
68
- Atoms::ProtectedWorkspacePruneInput.freeze_value(request)
69
- deadline = @wire.deadline(300)
70
- admission_deadline = [deadline, @wire.deadline(5)].min
71
- connect(admission_deadline) do |socket, observed|
72
- unless digest(observed) == digest(operation_owner_binding)
73
- raise SecurityError, "cleanup execute owner differs from original dispatch"
74
- end
75
- frame = request.merge("schema" => SCHEMA, "kind" => "execute",
76
- "operation_owner_binding_digest" => digest(observed))
77
- @wire.write(socket, frame, deadline: admission_deadline, limit: 65_536)
78
- socket.shutdown(Socket::SHUT_WR)
79
- guarded = Ace::Runtime::Molecules::ProtectedSocket::Ingress.new(socket)
80
- reply = @wire.read(guarded, deadline: deadline, limit: LIMIT)
81
- unless reply.is_a?(Hash) && reply.keys.sort == %w[input_digest kind receipt_ref request_id schema] &&
82
- reply.values_at("schema", "kind", "request_id", "input_digest") ==
83
- [SCHEMA, "result", request.fetch("request_id"), request.fetch("input_digest")]
84
- raise SecurityError, "cleanup result identity differs"
85
- end
86
- ref = reply.fetch("receipt_ref")
87
- Atoms::ProtectedWorkspacePruneInput.reference!(ref)
88
- descriptor = {"version" => 1, "bytes" => ref.fetch("bytes"), "sha256" => ref.fetch("sha256"),
89
- "parts" => [ref.slice("bytes", "sha256")]}
90
- @codec.receive(guarded, descriptor: descriptor, purpose: :artifacts, deadline: deadline) do |received|
91
- Atoms::ProtectedWorkspacePruneInput.freeze_value(ref)
92
- {receipt_ref: ref, bytes: received.bytes.freeze}.freeze
93
- end
94
- end
95
- end
96
-
97
- def inspect!(request:, operation_owner_binding:)
98
- request = JSON.parse(JSON.generate(request), create_additions: false, max_nesting: 16)
99
- fields = %w[request_id input_digest claim_binding request_event_digest dispatch_event_digest input challenge_ref]
100
- Atoms::ProtectedWorkspacePruneInput.object!(request, fields)
101
- Atoms::ProtectedWorkspacePruneInput.token!(request.fetch("request_id"))
102
- (fields - %w[request_id input challenge_ref]).each { |key| Atoms::ProtectedWorkspacePruneInput.digest!(request.fetch(key)) }
103
- Atoms::ProtectedWorkspacePruneInput.object!(request.fetch("challenge_ref"), %w[challenge_event_digest])
104
- Atoms::ProtectedWorkspacePruneInput.digest!(request.dig("challenge_ref", "challenge_event_digest"))
105
- input = Atoms::ProtectedWorkspacePruneInput.parse(JSON.generate(request.fetch("input")))
106
- raise SecurityError, "cleanup inspection input differs" unless Atoms::ServiceInput.digest(input) == request.fetch("input_digest")
107
- frame = request.merge("schema" => SCHEMA, "kind" => "inspect", "operation_owner_binding_digest" => digest(operation_owner_binding))
108
- Atoms::ProtectedWorkspacePruneInput.freeze_value(frame)
109
- deadline = @wire.deadline(300)
110
- admission_deadline = [deadline, @wire.deadline(5)].min
111
- connect(admission_deadline) do |socket, _observed|
112
- @wire.write(socket, frame, deadline: admission_deadline, limit: 65_536)
113
- socket.shutdown(Socket::SHUT_WR)
114
- guarded = Ace::Runtime::Molecules::ProtectedSocket::Ingress.new(socket)
115
- reply = @wire.read(guarded, deadline: deadline, limit: LIMIT)
116
- success = reply.is_a?(Hash) && reply["kind"] == "completed-result"
117
- ref_key = success ? "receipt_ref" : "inspection_ref"
118
- unless reply.is_a?(Hash) && reply.keys.sort == (["input_digest", ref_key, "kind", "request_id", "schema"].sort) &&
119
- reply.values_at("schema", "kind", "request_id", "input_digest") ==
120
- [SCHEMA, success ? "completed-result" : "inspection", request.fetch("request_id"), request.fetch("input_digest")]
121
- raise SecurityError, "cleanup inspection result differs"
122
- end
123
- ref = reply.fetch(ref_key)
124
- Atoms::ProtectedWorkspacePruneInput.reference!(ref)
125
- descriptor = {"version" => 1, "bytes" => ref.fetch("bytes"), "sha256" => ref.fetch("sha256"), "parts" => [ref.slice("bytes", "sha256")]}
126
- @codec.receive(guarded, descriptor: descriptor, purpose: :artifacts, deadline: deadline) do |received|
127
- Atoms::ProtectedWorkspacePruneInput.freeze_value(ref)
128
- {kind: success ? "succeeded" : "failed-no-effect", receipt_ref: ref, bytes: received.bytes.freeze}.freeze
129
- end
130
- end
131
- end
132
-
133
- private
134
-
135
- def digest(value) = Ace::Assign::Atoms::EvidenceDigest.digest(value)
136
-
137
- def connect(deadline)
138
- @wire.root_path!(File.dirname(PATH), directory: true)
139
- before = @wire.socket_identity(PATH, mode: 0o660)
140
- raise SecurityError, "cleanup endpoint owner differs" unless before.last == 0
141
- @wire.connect(PATH, deadline: deadline) do |socket|
142
- observed = @observer.observe!(socket: socket, deadline: deadline)
143
- raise SecurityError, "cleanup endpoint was replaced" unless @wire.socket_identity(PATH, mode: 0o660) == before
144
- yield socket, observed
145
- end
146
- rescue IOError, SystemCallError
147
- raise Ace::Runtime::RuntimeUnavailableError, "cleanup original transport unavailable"
148
- end
149
-
150
- def eof!(socket, deadline)
151
- loop do
152
- remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
153
- unless remaining.positive? && IO.select([socket], nil, nil, remaining)
154
- raise Ace::Runtime::RuntimeUnavailableError, "cleanup reply EOF deadline expired"
155
- end
156
- byte = socket.read_nonblock(1, exception: false)
157
- next if byte == :wait_readable
158
- raise SecurityError, "cleanup reply has trailing bytes" unless byte.nil?
159
- return
160
- end
161
- end
162
- end
163
- end
164
- end
165
- end
@@ -1,239 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "fiddle"
4
- require "socket"
5
- require "ace/runtime/molecules/process_identity"
6
- require "ace/runtime/molecules/systemd_scope_manager"
7
- require "ace/runtime/molecules/protected_artifact_set"
8
-
9
- module Ace
10
- module Lab
11
- module Molecules
12
- # Only trusted accepted-release composition constructs this observer.
13
- # Root policy is fixed here; ordinary ProtectedLinux stays all-zero.
14
- class ProtectedCleanupOwnerIdentity
15
- Runtime = Ace::Runtime::Molecules
16
- Unavailable = Ace::Runtime::RuntimeUnavailableError
17
- DISABLE = "--disable=gems,rubyopt,did_you_mean,error_highlight,syntax_suggest"
18
- UNIT_FIELDS = {"Id" => "s", "LoadState" => "s", "ActiveState" => "s", "SubState" => "s",
19
- "InvocationID" => "ay", "FragmentPath" => "s", "DropInPaths" => "as"}.freeze
20
- EMPTY_COMMANDS = %w[ExecConditionEx ExecStartPreEx ExecStartPostEx ExecReloadEx ExecStopEx ExecStopPostEx].freeze
21
- ENVIRONMENT = %w[PATH=/usr/bin:/bin LANG=C LC_ALL=C HOME=/root].freeze
22
- MANAGER_ENVIRONMENT = %w[PATH LANG LANGUAGE LC_ALL LC_CTYPE LC_NUMERIC LC_TIME LC_COLLATE LC_MONETARY
23
- LC_MESSAGES LC_PAPER LC_NAME LC_ADDRESS LC_TELEPHONE LC_MEASUREMENT LC_IDENTIFICATION].freeze
24
- ENVIRONMENT_FIELDS = {"Environment" => "as", "EnvironmentFiles" => "a(sb)", "PassEnvironment" => "as",
25
- "UnsetEnvironment" => "as", "PAMName" => "s"}.freeze
26
- SERVICE_FIELDS = {**ENVIRONMENT_FIELDS, **EMPTY_COMMANDS.to_h { |key| [key, "a(sasasttttuii)"] }, "Slice" => "s", "Type" => "s", "MainPID" => "u", "ControlPID" => "u", "ExecStartEx" => "a(sasasttttuii)"}.freeze
27
-
28
- class Kernel
29
- def initialize(identity: Runtime::ProcessIdentity.new, pidfd_open: nil)
30
- @identity = identity
31
- @pidfd_open = pidfd_open || method(:open_pidfd)
32
- end
33
-
34
- def capture(pid)
35
- raise Unavailable, "root owner requires Linux" unless RUBY_PLATFORM.include?("linux") && File.binread("/proc/sys/kernel/yama/ptrace_scope", 128).strip == "2"
36
- unless pid.is_a?(Integer) && pid.positive?
37
- raise Unavailable, "root owner PID is unavailable"
38
- end
39
- birth = @identity.send(:native_birth, pid)
40
- raise Unavailable, "root owner birth is unavailable" unless birth
41
- bytes = File.binread("/proc/#{pid}/status", 65_537)
42
- raise Unavailable, "root owner status is oversized" if bytes.bytesize > 65_536
43
- fields = bytes.lines.to_h { |line| line.strip.split(/:\s*/, 2) }
44
- ids = %w[Uid Gid].map { |key| fields.fetch(key).split.map { |id| Integer(id, 10) } }
45
- caps = %w[CapInh CapPrm CapEff CapBnd CapAmb].map { |key| Integer(fields.fetch(key), 16) }
46
- groups = fields.fetch("Groups").split.map { |id| Integer(id, 10) }.sort
47
- unless ids.all? { |value| value == [0, 0, 0, 0] } && groups == [0] &&
48
- caps == [0, 2, 2, 2, 0] && fields.fetch("NoNewPrivs") == "1" &&
49
- @identity.send(:native_birth, pid) == birth
50
- raise Unavailable, "root owner kernel policy differs"
51
- end
52
- {"pid" => pid, "uid" => 0, "gid" => 0, "groups" => groups,
53
- "started_at" => birth, "host" => Socket.gethostname,
54
- "parent_pid" => Integer(fields.fetch("PPid"), 10)}
55
- rescue IOError, SystemCallError, KeyError, ArgumentError
56
- raise Unavailable, "root owner kernel policy is unavailable"
57
- end
58
-
59
- def pin(identity)
60
- handle = @pidfd_open.call(identity.fetch("pid"))
61
- unless handle.is_a?(IO) && !handle.closed?
62
- raise Unavailable, "root owner pidfd is unavailable"
63
- end
64
- unless capture(identity.fetch("pid")) == identity
65
- raise Unavailable, "root owner changed during pin acquisition"
66
- end
67
- handle
68
- rescue Fiddle::DLError, SystemCallError, IOError
69
- handle.close if handle.is_a?(IO) && !handle.closed?
70
- raise Unavailable, "root owner pidfd capability is unavailable"
71
- rescue Exception
72
- handle.close if handle.is_a?(IO) && !handle.closed?
73
- raise
74
- end
75
-
76
- def open_pidfd(pid)
77
- function = Fiddle::Function.new(Fiddle::Handle::DEFAULT["pidfd_open"],
78
- [Fiddle::TYPE_INT, Fiddle::TYPE_INT], Fiddle::TYPE_INT)
79
- fd = function.call(pid, 0)
80
- raise Unavailable, "root owner pidfd is unavailable" if fd.negative?
81
- IO.for_fd(fd, autoclose: true)
82
- end
83
- private :open_pidfd
84
-
85
- def exited?(handle) = !IO.select([handle], nil, nil, 0).nil?
86
-
87
- def peer(socket)
88
- raise Unavailable, "root peer requires Linux" unless RUBY_PLATFORM.include?("linux")
89
- socket.getsockopt(Socket::SOL_SOCKET, Socket::SO_PEERCRED).data.unpack("i!3")
90
- end
91
- end
92
-
93
- def initialize(unit:, slice_unit:, entry:, interpreter:, closure:, load_paths:, manager:, kernel: Kernel.new, artifacts: nil)
94
- unless unit.is_a?(String) && Runtime::SystemdScopeManager::UNIT.match?(unit) && unit.end_with?(".service") &&
95
- slice_unit.is_a?(String) && Runtime::SystemdScopeManager::UNIT.match?(slice_unit) && slice_unit.end_with?(".slice") &&
96
- !%w[system.slice user.slice machine.slice].include?(slice_unit) &&
97
- closure.is_a?(Array) && closure.size.between?(1, 4094) &&
98
- load_paths.is_a?(Array) && load_paths.size.between?(1, 64) && load_paths.uniq == load_paths &&
99
- load_paths.all? { |path| path.is_a?(String) && path.start_with?("/") && !path.include?("\0") && !path.include?(":") && File.expand_path(path) == path }
100
- raise ArgumentError, "cleanup identity requires a fixed accepted-release selection"
101
- end
102
- @unit = unit.dup.freeze
103
- @slice_unit = slice_unit.dup.freeze
104
- @references = [entry, interpreter, *closure].map { |ref| reference(ref) }.freeze
105
- unless @references.map { |ref| ref.fetch("path") }.uniq.size == @references.size
106
- raise ArgumentError, "cleanup closure has duplicate references"
107
- end
108
- unless load_paths.all? { |path| @references.any? { |ref| ref.fetch("path").start_with?(path + "/") } }
109
- raise ArgumentError, "cleanup load path is outside the held closure"
110
- end
111
- @entry, @interpreter = @references.first(2)
112
- @argv = [@interpreter.fetch("path"), DISABLE, "-I", load_paths.join(":"), @entry.fetch("path")].freeze
113
- unless @argv.sum { |argument| argument.bytesize + 1 } <= 16_384
114
- raise ArgumentError, "cleanup startup arguments exceed bound"
115
- end
116
- @manager, @kernel = manager, kernel
117
- @verification = Mutex.new
118
- @artifacts = artifacts || Runtime::ProtectedArtifactSet.new(file_limit: 32 * 1_048_576,
119
- total_limit: 256 * 1_048_576, count_limit: 4096)
120
- end
121
-
122
- def observe!(socket:, deadline: Runtime::ProtectedSocket.deadline(5))
123
- with_verification do
124
- observe_held!(deadline: deadline, peer_join: ->(identity) {
125
- @kernel.peer(socket) == [identity.fetch("pid"), 0, 0]
126
- })
127
- end
128
- end
129
-
130
- # Only the immutable installed listener calls this entrypoint. Its
131
- # positive receiver peer is not the root process being measured.
132
- def observe_self!(deadline: Runtime::ProtectedSocket.deadline(5))
133
- pid = Process.pid
134
- with_verification do
135
- observe_held!(deadline: deadline, peer_join: ->(identity) {
136
- identity.fetch("pid") == pid
137
- })
138
- end
139
- end
140
-
141
- def with_verification
142
- unless @verification.try_lock
143
- raise Unavailable, "cleanup identity verification is busy"
144
- end
145
- begin
146
- yield
147
- ensure
148
- @verification.unlock
149
- end
150
- end
151
- private :with_verification
152
-
153
- def observe_held!(deadline:, peer_join:)
154
- now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
155
- unless deadline.is_a?(Numeric) && deadline.finite? && deadline > now
156
- raise Unavailable, "cleanup identity observation deadline expired"
157
- end
158
- deadline = [deadline, now + 5].min
159
- before = snapshot(deadline)
160
- identity = @kernel.capture(before.fetch("MainPID"))
161
- handle = @kernel.pin(identity)
162
- @artifacts.with do |artifacts|
163
- @references.each { |ref| artifacts.read!(ref) }
164
- unless @references.any? { |ref| ref.fetch("path") == before.fetch("FragmentPath") }
165
- raise Unavailable, "installed cleanup unit is outside the held closure"
166
- end
167
- join = @manager.unit_for_pidfd(handle: handle, timeout: remaining(deadline))
168
- unless join == {"unit" => @unit, "invocation_id" => before.fetch("InvocationID")} &&
169
- peer_join.call(identity) && !@kernel.exited?(handle) &&
170
- @kernel.capture(identity.fetch("pid")) == identity && snapshot(deadline) == before
171
- raise Unavailable, "cleanup peer differs from the original installed invocation"
172
- end
173
- artifacts.verify_unchanged!
174
- unless !@kernel.exited?(handle) && @kernel.capture(identity.fetch("pid")) == identity &&
175
- @manager.unit_for_pidfd(handle: handle, timeout: remaining(deadline)) == join
176
- raise Unavailable, "cleanup owner changed after held source verification"
177
- end
178
- deep_freeze({"unit" => @unit, "invocation_id" => join.fetch("invocation_id"),
179
- "process_binding" => identity, "entry_sha256" => @entry.fetch("sha256")})
180
- end
181
- ensure
182
- handle&.close
183
- end
184
- private :observe_held!
185
-
186
- private
187
-
188
- def snapshot(deadline)
189
- unit = @manager.typed_properties(unit: @unit, interface: "Unit", signatures: UNIT_FIELDS, timeout: remaining(deadline))
190
- service = @manager.typed_properties(unit: @unit, interface: "Service", signatures: SERVICE_FIELDS, timeout: remaining(deadline))
191
- environment = @manager.manager_environment(timeout: remaining(deadline))
192
- unless environment.is_a?(Array) && environment.all? { |entry| entry.is_a?(String) && MANAGER_ENVIRONMENT.include?(entry.split("=", 2).first) } &&
193
- service.fetch("Environment").sort == ENVIRONMENT.sort &&
194
- %w[EnvironmentFiles PassEnvironment UnsetEnvironment].all? { |key| service.fetch(key) == [] } && service.fetch("PAMName") == ""
195
- raise Unavailable, "cleanup original startup environment differs"
196
- end
197
- start = service.fetch("ExecStartEx")
198
- unless unit.values_at("Id", "LoadState", "ActiveState", "SubState", "DropInPaths") == [@unit, "loaded", "active", "running", []] &&
199
- unit.fetch("InvocationID").match?(/\A[0-9a-f]{32}\z/) && unit.fetch("InvocationID") != "0" * 32 &&
200
- service.fetch("Slice") == @slice_unit &&
201
- EMPTY_COMMANDS.all? { |key| service[key] == [] } && service.fetch("Type") == "exec" && service.fetch("MainPID").is_a?(Integer) && service.fetch("MainPID").positive? &&
202
- service.fetch("ControlPID") == 0 && start.is_a?(Array) && start.size == 1 &&
203
- start[0][0] == @interpreter.fetch("path") && start[0][1] == @argv && start[0][2] == [] &&
204
- start[0][3].positive? && start[0][7] == service.fetch("MainPID") && start[0][8..9] == [0, 0]
205
- raise Unavailable, "cleanup unit execution-start configuration differs"
206
- end
207
- unit.merge(service).merge("EffectiveManagerEnvironment" => environment)
208
- rescue KeyError, NoMethodError, TypeError
209
- raise Unavailable, "cleanup unit execution facts are unavailable"
210
- end
211
-
212
- def remaining(deadline)
213
- value = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
214
- raise Unavailable, "cleanup identity observation deadline expired" unless value.positive?
215
- [value, 5].min
216
- end
217
-
218
- def reference(ref)
219
- unless ref.is_a?(Hash) && ref.keys.sort == %w[bytes path sha256] &&
220
- ref["path"].is_a?(String) && ref["path"].start_with?("/") && !ref["path"].include?("\0") && File.expand_path(ref["path"]) == ref["path"] &&
221
- ref["sha256"].is_a?(String) && ref["sha256"].match?(/\A[0-9a-f]{64}\z/) &&
222
- ref["bytes"].is_a?(Integer) && ref["bytes"].between?(1, 32 * 1_048_576)
223
- raise ArgumentError, "cleanup closure reference is malformed"
224
- end
225
- deep_freeze(ref)
226
- end
227
-
228
- def deep_freeze(value)
229
- case value
230
- when Hash then value.to_h { |key, item| [key.dup.freeze, deep_freeze(item)] }.freeze
231
- when Array then value.map { |item| deep_freeze(item) }.freeze
232
- when String then value.dup.freeze
233
- else value.freeze
234
- end
235
- end
236
- end
237
- end
238
- end
239
- end