ace-lab 0.4.0 → 0.5.1

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 (30) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/docs/usage.md +10 -0
  4. data/lib/ace/lab/atoms/protected_publication_input.rb +50 -0
  5. data/lib/ace/lab/atoms/protected_workspace_prune_input.rb +114 -0
  6. data/lib/ace/lab/atoms/protected_workspace_prune_preview.rb +86 -0
  7. data/lib/ace/lab/atoms/service_input.rb +20 -8
  8. data/lib/ace/lab/authorization_path.rb +20 -0
  9. data/lib/ace/lab/cli/commands/service.rb +45 -0
  10. data/lib/ace/lab/errors.rb +11 -0
  11. data/lib/ace/lab/molecules/grant_resolver.rb +2 -0
  12. data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +163 -0
  13. data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +165 -0
  14. data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +239 -0
  15. data/lib/ace/lab/molecules/protected_publication_publisher.rb +110 -0
  16. data/lib/ace/lab/molecules/protected_service_handler.rb +9 -40
  17. data/lib/ace/lab/molecules/protected_service_policy.rb +49 -5
  18. data/lib/ace/lab/molecules/service_policy.rb +26 -0
  19. data/lib/ace/lab/organisms/authority_composition.rb +25 -4
  20. data/lib/ace/lab/organisms/protected_cleanup_owner.rb +361 -0
  21. data/lib/ace/lab/organisms/protected_service_client.rb +129 -0
  22. data/lib/ace/lab/organisms/protected_service_listener.rb +181 -0
  23. data/lib/ace/lab/organisms/protected_service_publication.rb +268 -0
  24. data/lib/ace/lab/organisms/protected_service_receiver.rb +101 -11
  25. data/lib/ace/lab/organisms/protected_service_recovery.rb +185 -0
  26. data/lib/ace/lab/organisms/protected_service_request.rb +128 -0
  27. data/lib/ace/lab/organisms/protected_service_worker.rb +89 -0
  28. data/lib/ace/lab/version.rb +1 -1
  29. data/lib/ace/lab.rb +2 -22
  30. metadata +36 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bbfb351af77987d179094c39d14b12ac6209ba6825bc680e932df4fcf484684d
4
- data.tar.gz: 92d60f3d735582fc4371569d1f2d19691820d924855564ac95ac71eba9e06ecf
3
+ metadata.gz: c50694633d1faa21137c917494c8ca7c285f05b84660e1d6054114de62803e14
4
+ data.tar.gz: 88d0d94b87fed92e00cd0e4cc8a93e8e40821da3967fe7a92283c13efbf813a9
5
5
  SHA512:
6
- metadata.gz: a088f9ce2372cf71bfabe2df3f58b578ced3b1dae4f28ac1d791c90af64b4ac2795020e3d7ecbd8158cab7c75e2be6413250b22a7413459e466dc5d155404fe9
7
- data.tar.gz: 38031154d986e6d25fcfac08ee890cf619553ff26536327b128076f4fbec7c862a76bf6e04c438aea2bd87a6eb566aa82299959809c44fefd00c1edb0ab7433f
6
+ metadata.gz: b62e4b53841b9104643d6e432cd8d80e298c9508f7b9d9b630b683ef0065826b0664109f2bb84247addaa9eca5c854ba5d042adaa548930792321e64cbf0d926
7
+ data.tar.gz: 9697250de3414f290990c5cfadec04a9ad206f17fc98f57edfc3d47f38d0d37c42192145de69c5e806cd3f61ced375d9ee216ee2573deca04fb35757e02b6129
data/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.1] - 2026-10-10
11
+
12
+ ### Fixed
13
+
14
+ - Require the published producer and consumer patch versions containing the protected context and managed-attempt APIs; exclude incompatible prior dependency graphs.
15
+
16
+ ## [0.5.0] - 2026-10-08
17
+
18
+ ### Added
19
+
20
+ - Deliver scoped RubyGems publication through the fixed receiver and canonical HITL authorization, preserving original request, OTP challenge and publication-result evidence.
21
+
22
+ ### Changed
23
+
24
+ - Compose actual service/result owners without a read-proof completion handler; retain authenticated service settlement and cleanup boundaries.
25
+
26
+ ### Fixed
27
+
28
+ - Load protected policy/listener dependencies directly and keep cleanup inspection tied to the original receiver and immutable result evidence.
29
+
10
30
  ## [0.4.0] - 2026-10-05
11
31
 
12
32
  ### Added
data/docs/usage.md CHANGED
@@ -282,3 +282,13 @@ reads; it never provisions.
282
282
 
283
283
  `ace-assign`'s evidence journal. The Lab execution binary
284
284
  (`/usr/local/bin/lab`) is never invoked or required.
285
+
286
+ ### Protected handler cleanup boundary
287
+
288
+ Handlers retain the fixed closed environment and 30-second execution deadline, with 16KiB stdout and 8KiB stderr caps. Owned cleanup adds at most one second of confirmed reaping; unresolved cleanup remains uncertain and cannot return a service receipt. It cannot establish an arbitrary domain target or surviving writer is absent. The gad.b operation-specific fresh inspector remains required source work; installed verification is centralized in gad.2.
289
+
290
+ ### Selected receiver no-effect recovery
291
+
292
+ The existing protected receiver exposes `recover_no_effect(binding:, input_bytes:, mutation_id:, expected_generation:)`. Supply the original assignment/attempt/candidate/head/request binding, original structured input, stable mutation ID and unchanged original positive authority generation. The receiver snapshots those inputs, authenticates canonical status and input before any settled return, and adopts an already accepted current challenge without claiming again. A needed claim uses the supplied generation unchanged. Lost acknowledgements remain uncertain; status retry never repeats the original effect.
293
+
294
+ Trusted operation configuration must provide a distinct fixed `no_effect_argv` for the original project/service/executor. Inspection may proceed after the effect lease expires, but it never renews effect permission or selects normal effect argv. The selected inspector must produce operation-specific original target/handler/writer observations and existing strict challenge-bound artifacts; only the authority's canonical import can establish `failed-settled`. This source API and controlled composition do not deliver the outstanding gad.b inspector or installed acceptance.
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "service_input"
4
+
5
+ module Ace
6
+ module Lab
7
+ module Atoms
8
+ # One accepted artifact, never a queue selector or a build instruction.
9
+ module ProtectedPublicationInput
10
+ REGISTRY = "https://rubygems.org"
11
+ PUBLICATION_FIELDS = %w[gem_name version head registry artifact_relative_path].freeze
12
+ NAME = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
13
+ VERSION = /\A[0-9]+(?:\.[0-9A-Za-z]+)*(?:-[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?\z/
14
+
15
+ def self.decode!(bytes)
16
+ unless bytes.is_a?(String) && bytes.bytesize.between?(1, ServiceInput::MAX_BYTES)
17
+ raise ArgumentError, "publication input exceeds its bound"
18
+ end
19
+ input = JSON.parse(bytes, allow_duplicate_key: false, allow_comments: false,
20
+ allow_nan: false, create_additions: false, max_nesting: 16)
21
+ validate!(input)
22
+ rescue JSON::ParserError
23
+ raise ArgumentError, "publication input must be valid JSON"
24
+ end
25
+
26
+ def self.validate!(input)
27
+ unless input.is_a?(Hash) && input.keys.sort == %w[publication target] &&
28
+ input["publication"].is_a?(Hash) && input["publication"].keys.sort == PUBLICATION_FIELDS.sort &&
29
+ input["target"].is_a?(Hash) && input["target"].keys.sort == %w[artifact_digest resource]
30
+ raise ArgumentError, "publication input fields differ"
31
+ end
32
+ value = input.fetch("publication")
33
+ name, version, head, registry, path = value.values_at(*PUBLICATION_FIELDS)
34
+ unless name.is_a?(String) && name.match?(NAME) && version.is_a?(String) &&
35
+ version.bytesize.between?(1, 128) && version.match?(VERSION) &&
36
+ head.is_a?(String) && head.match?(/\A[0-9a-f]{40}\z/) && registry == REGISTRY &&
37
+ path.is_a?(String) && path.bytesize.between?(1, 4096) && path.end_with?(".gem") &&
38
+ path.split("/", -1).all? { |part| part.match?(/\A[a-zA-Z0-9_.-]+\z/) && !%w[. ..].include?(part) } &&
39
+ input.dig("target", "resource") == "rubygems:#{name}:#{version}" &&
40
+ input.dig("target", "artifact_digest").is_a?(String) &&
41
+ input.dig("target", "artifact_digest").match?(ServiceInput::SHA256)
42
+ raise ArgumentError, "publication artifact selection differs"
43
+ end
44
+ ServiceInput.validate!(input)
45
+ input
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module Lab
7
+ module Atoms
8
+ # Structural input validation only. Canonical ownership, preservation and
9
+ # current permission are independently verified by the operation owners.
10
+ module ProtectedWorkspacePruneInput
11
+ MAX_BYTES = 65_536
12
+ ID = /\A[A-Za-z0-9_.-]{1,200}\z/
13
+ SHA = /\A[0-9a-f]{64}\z/
14
+ COMMIT = /\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/
15
+ ASSOCIATION = %w[project_id mapping_id assignment_id attempt_id].freeze
16
+
17
+ module_function
18
+
19
+ def parse(bytes)
20
+ reject! unless bytes.is_a?(String) && bytes.bytesize.between?(1, MAX_BYTES)
21
+ text = bytes.dup.force_encoding(Encoding::UTF_8)
22
+ reject! unless text.valid_encoding?
23
+ input = JSON.parse(text, create_additions: false, max_nesting: 16,
24
+ allow_nan: false, allow_comments: false, allow_duplicate_key: false)
25
+ object!(input, %w[schema maintenance target publication preservation])
26
+ reject! unless input.fetch("schema") == "ace.protected-workspace-prune/v1"
27
+ association!(input.fetch("maintenance"))
28
+ target = object!(input.fetch("target"), ASSOCIATION +
29
+ %w[resource artifact_digest descriptor_sha256 binding_event_digest release_event_digest journal_commit])
30
+ ASSOCIATION.each { |field| token!(target.fetch(field)) }
31
+ %w[artifact_digest descriptor_sha256 binding_event_digest release_event_digest].each { |key| digest!(target.fetch(key)) }
32
+ commit!(target.fetch("journal_commit"))
33
+ resource = "workspace:#{target.fetch('project_id')}:#{target.fetch('mapping_id')}:#{target.fetch('assignment_id')}"
34
+ reject! unless resource.bytesize <= 256 && target.fetch("resource") == resource
35
+ publication = object!(input.fetch("publication"), %w[descriptor_sha256 installation_ref])
36
+ digest!(publication.fetch("descriptor_sha256"))
37
+ reference!(publication.fetch("installation_ref"))
38
+ preservation!(input.fetch("preservation"))
39
+ freeze_value(input)
40
+ rescue JSON::ParserError, JSON::NestingError, EncodingError
41
+ reject!
42
+ end
43
+
44
+ def preservation!(value)
45
+ object!(value, %w[head branch destinations manifest_sha256])
46
+ commit!(value.fetch("head"))
47
+ branch = value.fetch("branch")
48
+ unless branch.nil?
49
+ ref!(branch)
50
+ reject! unless branch.start_with?("refs/heads/")
51
+ end
52
+ digest!(value.fetch("manifest_sha256"))
53
+ destinations = value.fetch("destinations")
54
+ reject! unless destinations.is_a?(Array) && destinations.length <= 64
55
+ identities = destinations.map do |entry|
56
+ object!(entry, %w[repository_id ref head])
57
+ token!(entry.fetch("repository_id"))
58
+ ref!(entry.fetch("ref"))
59
+ commit!(entry.fetch("head"))
60
+ entry.values_at("repository_id", "ref", "head")
61
+ end
62
+ reject! unless identities == identities.sort && identities.uniq == identities
63
+ end
64
+
65
+ def association!(value)
66
+ object!(value, ASSOCIATION)
67
+ value.each_value { |item| token!(item) }
68
+ end
69
+
70
+ def reference!(value)
71
+ object!(value, %w[path bytes sha256])
72
+ path = value.fetch("path")
73
+ reject! unless path.is_a?(String) && path.bytesize.between?(2, 4096) &&
74
+ path.start_with?("/") && !path.include?("\0") && !path.end_with?("/") &&
75
+ path.split("/", -1).drop(1).all? { |part| !part.empty? && !%w[. ..].include?(part) }
76
+ reject! unless value.fetch("bytes").is_a?(Integer) && value.fetch("bytes").between?(1, MAX_BYTES)
77
+ digest!(value.fetch("sha256"))
78
+ end
79
+
80
+ def object!(value, fields)
81
+ reject! unless value.is_a?(Hash) && value.keys.sort == fields.sort
82
+ value
83
+ end
84
+
85
+ def token!(value)
86
+ reject! unless value.is_a?(String) && ID.match?(value)
87
+ end
88
+
89
+ def digest!(value)
90
+ reject! unless value.is_a?(String) && SHA.match?(value)
91
+ end
92
+
93
+ def commit!(value)
94
+ reject! unless value.is_a?(String) && COMMIT.match?(value)
95
+ end
96
+
97
+ def ref!(value)
98
+ reject! unless value.is_a?(String) && value.bytesize.between?(6, 256) &&
99
+ value.start_with?("refs/") && !value.match?(/[\x00-\x20\x7f~^:?*\[\\]/) &&
100
+ !value.include?("..") && !value.include?("@{") && !value.end_with?(".") &&
101
+ value.split("/", -1).all? { |part| !part.empty? && !part.start_with?(".") && !part.end_with?(".lock") }
102
+ end
103
+
104
+ def freeze_value(value)
105
+ value.each { |key, item| key.freeze; freeze_value(item) } if value.is_a?(Hash)
106
+ value.each { |item| freeze_value(item) } if value.is_a?(Array)
107
+ value.freeze
108
+ end
109
+
110
+ def reject! = raise(ArgumentError, "invalid protected prune input")
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "protected_workspace_prune_input"
4
+ require "ace/assign/molecules/execution_scope_lineage"
5
+ require "ace/assign/atoms/evidence_digest"
6
+
7
+ module Ace
8
+ module Lab
9
+ module Atoms
10
+ # Closed transport data only; canonical and physical owners must
11
+ # independently authenticate these selectors before any effect.
12
+ module ProtectedWorkspacePrunePreview
13
+ Input = ProtectedWorkspacePruneInput
14
+ CONTEXT = %w[journal_commit maintenance head candidate_generation worker_process_binding caller_process_binding].freeze
15
+ RESULT = %w[schema kind intent_digest publication maintenance maintenance_context target preservation inventory_sha256 file_count total_bytes].freeze
16
+ module_function
17
+
18
+ def intent!(value)
19
+ Input.object!(value, %w[schema maintenance target publication destinations])
20
+ Input.reject! unless value["schema"] == "ace.protected-workspace-prune-preview/v1"
21
+ Input.association!(value.fetch("maintenance"))
22
+ target = value.fetch("target")
23
+ Input.object!(target, Input::ASSOCIATION + %w[resource descriptor_sha256 binding_event_digest release_event_digest journal_commit])
24
+ Input::ASSOCIATION.each { |key| Input.token!(target.fetch(key)) }
25
+ %w[descriptor_sha256 binding_event_digest release_event_digest].each { |key| Input.digest!(target.fetch(key)) }
26
+ Input.commit!(target.fetch("journal_commit"))
27
+ expected = "workspace:#{target.fetch('project_id')}:#{target.fetch('mapping_id')}:#{target.fetch('assignment_id')}"
28
+ Input.reject! unless expected.bytesize <= 256 && target["resource"] == expected
29
+ publication = Input.object!(value.fetch("publication"), %w[descriptor_sha256 installation_ref])
30
+ Input.digest!(publication.fetch("descriptor_sha256"))
31
+ Input.reference!(publication.fetch("installation_ref"))
32
+ # Reuse the existing exact destination/ref owner, without accepting
33
+ # or manufacturing a preservation authority assertion.
34
+ destinations!(value.fetch("destinations"))
35
+ Input.reject! if JSON.generate(value).bytesize > Input::MAX_BYTES
36
+ Input.freeze_value(value)
37
+ end
38
+
39
+ def destinations!(destinations)
40
+ Input.reject! unless destinations.is_a?(Array) && destinations.length <= 64
41
+ ids = destinations.map do |entry|
42
+ Input.object!(entry, %w[repository_id ref head])
43
+ Input.token!(entry.fetch("repository_id")); Input.ref!(entry.fetch("ref")); Input.commit!(entry.fetch("head"))
44
+ entry.values_at("repository_id", "ref", "head")
45
+ end
46
+ Input.reject! unless ids == ids.sort && ids == ids.uniq
47
+ end
48
+
49
+ def context!(value)
50
+ Input.object!(value, CONTEXT)
51
+ Input.commit!(value.fetch("journal_commit"))
52
+ Input.association!(value.fetch("maintenance"))
53
+ Input.commit!(value.fetch("head"))
54
+ Input.reject! unless value["candidate_generation"].is_a?(Integer) && value["candidate_generation"].positive?
55
+ original = value.fetch("worker_process_binding")
56
+ birth = original.is_a?(Hash) && original["started_at"]
57
+ boot = birth.is_a?(String) && birth.match(/\Alinux:([0-9a-f-]{36}):[0-9]+\z/)&.captures&.first
58
+ %w[worker_process_binding caller_process_binding].each do |key|
59
+ Ace::Assign::Molecules::ExecutionScopeLineage.validate_process_identity!(value.fetch(key), boot_id: boot)
60
+ end
61
+ Input.reject! if JSON.generate(value).bytesize > 16_384
62
+ Input.freeze_value(value)
63
+ end
64
+
65
+ def result!(value, intent:, context:)
66
+ Input.object!(value, RESULT)
67
+ Input.reject! unless value.values_at("schema", "kind") == ["ace.protected-workspace-prune-preview-result/v1", "preview"]
68
+ Input.reject! unless value["intent_digest"] == Ace::Assign::Atoms::EvidenceDigest.digest(intent) &&
69
+ value["maintenance_context"] == context && value["maintenance"] == intent["maintenance"] &&
70
+ value["publication"] == intent["publication"] && value.fetch("target").except("artifact_digest") == intent["target"]
71
+ input = {"schema" => "ace.protected-workspace-prune/v1"}.merge(value.slice("maintenance", "target", "publication", "preservation"))
72
+ Input.parse(JSON.generate(input))
73
+ Input.digest!(value.fetch("inventory_sha256"))
74
+ Input.reject! unless value["inventory_sha256"] == value.dig("preservation", "manifest_sha256")
75
+ Input.reject! unless value["file_count"].is_a?(Integer) && value["file_count"].between?(0, 4096) &&
76
+ value["total_bytes"].is_a?(Integer) && value["total_bytes"].between?(0, 256 * 1024 * 1024)
77
+ Input.reject! unless value.dig("preservation", "destinations") == intent["destinations"] &&
78
+ value.dig("target", "artifact_digest") == Ace::Assign::Atoms::EvidenceDigest.digest(
79
+ "target" => intent.fetch("target"), "preservation" => value.fetch("preservation"))
80
+ Input.reject! if JSON.generate(value).bytesize > 16_384
81
+ Input.freeze_value(value)
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -15,16 +15,22 @@ module Ace
15
15
  RESOURCE = /\A[a-zA-Z0-9_.:\/-]{1,256}\z/
16
16
 
17
17
  def self.load(path)
18
- raise ArgumentError, "input file is missing" unless File.file?(path)
19
- # One handle bounds the read: a file replaced or grown between a
20
- # size check and a separate read cannot bypass the limit.
21
18
  begin
22
- data = File.open(path, "rb") do |file|
23
- content = file.read(MAX_BYTES + 1)
24
- raise ArgumentError, "input exceeds #{MAX_BYTES} bytes" if content && content.bytesize > MAX_BYTES
25
- JSON.parse(content.to_s)
19
+ flags = File::RDONLY | File::NOFOLLOW | File::NONBLOCK
20
+ data = File.open(path, flags) do |file|
21
+ before = file.stat
22
+ unless before.file? && before.size <= MAX_BYTES && same_file?(before, File.lstat(path))
23
+ raise ArgumentError, "input must be a bounded regular file"
24
+ end
25
+ content = file.read(MAX_BYTES + 1).to_s
26
+ unless content.bytesize <= MAX_BYTES && same_file?(before, file.stat) &&
27
+ same_file?(before, File.lstat(path))
28
+ raise ArgumentError, "input file changed while reading"
29
+ end
30
+ JSON.parse(content, allow_duplicate_key: false, allow_comments: false,
31
+ allow_nan: false, create_additions: false, max_nesting: 16)
26
32
  end
27
- rescue Errno::EACCES, Errno::EISDIR
33
+ rescue SystemCallError, IOError
28
34
  raise ArgumentError, "input file is unreadable"
29
35
  end
30
36
  raise ArgumentError, "input must be a JSON object" unless data.is_a?(Hash)
@@ -34,6 +40,12 @@ module Ace
34
40
  raise ArgumentError, "input must be valid JSON"
35
41
  end
36
42
 
43
+ def self.same_file?(left, right)
44
+ [left.dev, left.ino, left.mode, left.uid, left.gid, left.size, left.mtime, left.ctime] ==
45
+ [right.dev, right.ino, right.mode, right.uid, right.gid, right.size, right.mtime, right.ctime]
46
+ end
47
+ private_class_method :same_file?
48
+
37
49
  def self.digest(data)
38
50
  Digest::SHA256.hexdigest(JSON.generate(canonical(data)))
39
51
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Lab
5
+ # Authorization grants live in a single deployment-controlled file at a
6
+ # FIXED path — never in the caller-writable configuration cascade and
7
+ # never at a caller-selected location (review rounds 4-5, F3/F1). The
8
+ # file must be root-owned and not group/world-writable, including every
9
+ # directory on its real path; GrantResolver verifies and fails closed.
10
+ AUTHORIZATION_PATH = "/etc/lab/ace-lab/authorization.yml"
11
+
12
+ # Path of the trusted authorization grants document. A method (not a
13
+ # bare constant reference) so tests can stub the seam without any
14
+ # caller-controllable production override.
15
+ # @return [String]
16
+ def self.authorization_path
17
+ AUTHORIZATION_PATH
18
+ end
19
+ end
20
+ end
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require "ace/support/cli"
5
5
  require_relative "support"
6
+ require_relative "../../organisms/protected_service_request"
6
7
 
7
8
  module Ace
8
9
  module Lab
@@ -14,6 +15,16 @@ module Ace
14
15
  @service ||= Organisms::ServiceRequestService.new
15
16
  end
16
17
 
18
+ def protected_service
19
+ @protected_service ||= Organisms::ProtectedServiceRequest.new
20
+ end
21
+
22
+ def protected_refusal(error)
23
+ code = error.is_a?(ArgumentError) || error.is_a?(SecurityError) ? "protected_service_refused" : "protected_service_unavailable"
24
+ message = error.is_a?(JSON::ParserError) ? "protected installed selection unavailable" : error.message.to_s.scrub[0, 512]
25
+ emit({"status" => "error", "error" => {"code" => code, "message" => message}})
26
+ end
27
+
17
28
  def emit(envelope)
18
29
  puts JSON.generate(envelope)
19
30
  return if envelope["status"] == "ok"
@@ -39,11 +50,26 @@ module Ace
39
50
  option :request_id, type: :string, required: true, desc: "Idempotency key"
40
51
  option :dry_run, type: :boolean, desc: "Validate without claiming or executing"
41
52
 
53
+ option :mapping, type: :string, desc: "Original protected mapping ID"
54
+ option :scope, type: :string, desc: "Original hierarchical prepared scope"
55
+ option :service, type: :string, desc: "Installed receiver ID"
56
+ option :candidate_head, type: :string, desc: "Exact approved candidate head"
57
+ option :candidate_generation, type: :integer, desc: "Original candidate generation"
58
+ option :expected_generation, type: :integer, desc: "Original authority generation"
59
+
42
60
  def call(project:, assignment:, attempt:, operation:, input:, authorization:, request_id:, **options)
43
61
  reject_identity_flags!(options)
62
+ selected = protected_service.selected?(options)
63
+ if selected
64
+ return emit(protected_service.request(project: project, assignment: assignment, attempt: attempt,
65
+ operation: operation, input_path: input, authorization: authorization, request_id: request_id, **options))
66
+ end
44
67
  emit(service.request(project: project, assignment: assignment, attempt: attempt,
45
68
  operation: operation, input_path: input, authorization: authorization,
46
69
  request_id: request_id, dry_run: options[:dry_run]))
70
+ rescue ArgumentError, Ace::Assign::Error, Ace::Git::Error, Ace::Runtime::RuntimeUnavailableError, JSON::ParserError, SecurityError, KeyError, SystemCallError => error
71
+ raise if selected == false
72
+ protected_refusal(error)
47
73
  end
48
74
  end
49
75
 
@@ -56,9 +82,28 @@ module Ace
56
82
  option :request, type: :string, required: true, desc: "Request ID"
57
83
  option :format, type: :string, desc: "Output format (json only)"
58
84
 
85
+ option :project, type: :string, desc: "Original project ID"
86
+ option :assignment, type: :string, desc: "Original assignment ID"
87
+ option :attempt, type: :string, desc: "Original attempt ID"
88
+ option :mapping, type: :string, desc: "Original protected mapping ID"
89
+ option :scope, type: :string, desc: "Original hierarchical prepared scope"
90
+ option :candidate_head, type: :string, desc: "Exact approved candidate head"
91
+ option :candidate_generation, type: :integer, desc: "Original candidate generation"
92
+ option :input_digest, type: :string, desc: "Original normalized input SHA-256"
93
+ option :target, type: :string, desc: "Original target resource ID"
94
+ option :artifact_digest, type: :string, desc: "Original artifact SHA-256 (omit for null)"
95
+
59
96
  def call(request:, **options)
97
+ reject_identity_flags!(options)
60
98
  ensure_json_format!(options)
99
+ selected = protected_service.selected?(options)
100
+ if selected
101
+ return emit(protected_service.status(request_id: request, **options))
102
+ end
61
103
  emit(service.status(request_id: request))
104
+ rescue ArgumentError, Ace::Assign::Error, Ace::Git::Error, Ace::Runtime::RuntimeUnavailableError, JSON::ParserError, SecurityError, KeyError, SystemCallError => error
105
+ raise if selected == false
106
+ protected_refusal(error)
62
107
  end
63
108
  end
64
109
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Lab
5
+ class Error < StandardError; end
6
+
7
+ # Raised when topology configuration violates the schema contract
8
+ class InvalidConfigurationError < Error; end
9
+ end
10
+ end
11
+
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../errors"
3
4
  require "yaml"
5
+ require "date"
4
6
 
5
7
  module Ace
6
8
  module Lab
@@ -0,0 +1,163 @@
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