ace-lab 0.3.1 → 0.5.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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -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 +5 -1
  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 +43 -0
  17. data/lib/ace/lab/molecules/protected_service_policy.rb +156 -0
  18. data/lib/ace/lab/molecules/service_policy.rb +36 -1
  19. data/lib/ace/lab/organisms/authority_composition.rb +87 -0
  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 +267 -0
  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/organisms/service_request_service.rb +4 -2
  29. data/lib/ace/lab/version.rb +1 -1
  30. data/lib/ace/lab.rb +2 -22
  31. metadata +52 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1cd47480daa53d48cead3bb733e54223a0614394f27614248829ee4566a71da3
4
- data.tar.gz: 6960967135776f222d2b2c638a0317e3a2b1afceb811155046f87fb41d476d65
3
+ metadata.gz: 6e017c2a3431b82dc516951d8a3acc00eb8aac7965c037d921edd2c7b5f5a8a0
4
+ data.tar.gz: 53c907e67ab172535e09c7e046f939b1f59d8cf01cc2f18e0f6d0b1540cc8b4b
5
5
  SHA512:
6
- metadata.gz: 10939c1ffa2ca6045f8f6e526f9fc96be4dba7c4d453e483a785e4570c57356ad6f3bfaf62cf8b4a20c0373cce8f5a261d18ec77ec2607393bcc3cc1facd2e21
7
- data.tar.gz: 5f0d847b422a83437fd6431de880b7aa4f6e72c44dff00df9e0d359a85ba30600e0f70dbcb31d910ae34aa1f2a8a0419867c9b018f78b53273e588cc6645098e
6
+ metadata.gz: e3d6659be2c485710cca9d405e592c9783ab8cc07fd79aa23aa32e29a69d968f867889a540be6e5a7b3591b785853e9e1cff5f0ad19c75995e0f56950ffbf13a
7
+ data.tar.gz: c7e9961a4c48a8ae919ab3813286022897793ac1b52859bd8f634de7a2a4084531e521fc6039cdd9b2c81591a82621a81339dbdee94cffccacae06ad0a8c4fc1
data/CHANGELOG.md CHANGED
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-10-08
11
+
12
+ ### Added
13
+
14
+ - Deliver scoped RubyGems publication through the fixed receiver and canonical HITL authorization, preserving original request, OTP challenge and publication-result evidence.
15
+
16
+ ### Changed
17
+
18
+ - Compose actual service/result owners without a read-proof completion handler; retain authenticated service settlement and cleanup boundaries.
19
+
20
+ ### Fixed
21
+
22
+ - Load protected policy/listener dependencies directly and keep cleanup inspection tied to the original receiver and immutable result evidence.
23
+
24
+ ## [0.4.0] - 2026-10-05
25
+
26
+ ### Added
27
+
28
+ - Protected service policy, receiver and authority composition; incomplete full-service startup remains refused.
29
+ - Resolve immutable second-commander proposals through confirmed-delivery sixteen-hour policy and canonical Assign authorization.
30
+
31
+ ### Changed
32
+
33
+ - Declare the required direct dependencies and minimum producer versions for this coordinated release: `ace-assign ~> 0.64`, `ace-runtime ~> 0.2`.
34
+
10
35
  ## [0.3.1] - 2026-10-04
11
36
 
12
37
  ### Changed
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
@@ -33,7 +35,9 @@ module Ace
33
35
  raise Ace::Lab::InvalidConfigurationError,
34
36
  "invalid lab configuration: trusted authorization file must contain a mapping"
35
37
  end
36
- document
38
+ # Use the same principal schema and the same verified bytes for
39
+ # policy composition, rather than reading a second grants snapshot.
40
+ document.merge("principals" => parse_grants(content, path).fetch("principals"))
37
41
  rescue Psych::Exception
38
42
  raise Ace::Lab::InvalidConfigurationError,
39
43
  "invalid lab configuration: trusted authorization file could not be parsed"