ace-assign 0.71.0 → 0.73.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 (197) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +8 -8
  3. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +1 -1
  4. data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
  5. data/.ace-defaults/assign/config.yml +0 -13
  6. data/.ace-defaults/assign/presets/fix-bug.yml +1 -1
  7. data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +1 -1
  8. data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +1 -1
  9. data/.ace-defaults/assign/presets/work-on-task.yml +1 -1
  10. data/CHANGELOG.md +5 -2116
  11. data/README.md +58 -31
  12. data/docs/exit-codes.md +36 -44
  13. data/docs/getting-started.md +9 -4
  14. data/docs/usage.md +129 -522
  15. data/handbook/skills/as-assign-drive/SKILL.md +1 -4
  16. data/handbook/workflow-instructions/assign/drive.wf.md +47 -195
  17. data/handbook/workflow-instructions/assign/prepare.wf.md +1 -1
  18. data/handbook/workflow-instructions/assign/verify-test-suite.wf.md +8 -2
  19. data/lib/ace/assign/cli/commands/assignment_target.rb +2 -15
  20. data/lib/ace/assign/cli/commands/create.rb +1 -8
  21. data/lib/ace/assign/cli/commands/fail.rb +0 -6
  22. data/lib/ace/assign/cli/commands/finish.rb +2 -23
  23. data/lib/ace/assign/cli/commands/fork_session.rb +0 -1
  24. data/lib/ace/assign/cli/commands/select.rb +0 -1
  25. data/lib/ace/assign/cli/commands/start.rb +0 -2
  26. data/lib/ace/assign/cli/commands/status.rb +1 -31
  27. data/lib/ace/assign/cli/commands/step.rb +1 -3
  28. data/lib/ace/assign/cli.rb +1 -85
  29. data/lib/ace/assign/errors.rb +8 -80
  30. data/lib/ace/assign/models/assignment.rb +5 -28
  31. data/lib/ace/assign/molecules/assignment_manager.rb +0 -26
  32. data/lib/ace/assign/molecules/fork_session_launcher.rb +4 -12
  33. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +15 -112
  34. data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +6 -2
  35. data/lib/ace/assign/molecules/skill_assign_source_resolver.rb +9 -0
  36. data/lib/ace/assign/organisms/assignment_executor.rb +0 -6
  37. data/lib/ace/assign/organisms/task_assignment_creator.rb +3 -8
  38. data/lib/ace/assign/version.rb +1 -1
  39. data/lib/ace/assign.rb +0 -6
  40. metadata +22 -176
  41. data/.ace-defaults/assign/presets/close-delivery.yml +0 -57
  42. data/.ace-defaults/assign/presets/deliver-release.yml +0 -60
  43. data/.ace-defaults/assign/presets/integrate-batch.yml +0 -72
  44. data/.ace-defaults/assign/presets/supervise-delivery.yml +0 -69
  45. data/docs/protected-authority.md +0 -282
  46. data/exe/ace-scope-ready +0 -55
  47. data/lib/ace/assign/atoms/assignment_scope.rb +0 -58
  48. data/lib/ace/assign/atoms/attempt_state_machine.rb +0 -84
  49. data/lib/ace/assign/atoms/candidate_limits.rb +0 -20
  50. data/lib/ace/assign/atoms/delivery_parameters.rb +0 -19
  51. data/lib/ace/assign/atoms/evidence_digest.rb +0 -78
  52. data/lib/ace/assign/atoms/fixed_merge_schemas.rb +0 -398
  53. data/lib/ace/assign/atoms/git_blob_batch.rb +0 -59
  54. data/lib/ace/assign/authority/assignment_definition.rb +0 -35
  55. data/lib/ace/assign/authority/assignment_inventory.rb +0 -290
  56. data/lib/ace/assign/authority/campaign_consumer_policy.rb +0 -100
  57. data/lib/ace/assign/authority/campaign_execution.rb +0 -119
  58. data/lib/ace/assign/authority/campaign_round_transfer.rb +0 -82
  59. data/lib/ace/assign/authority/candidate_blob_batch.rb +0 -55
  60. data/lib/ace/assign/authority/candidate_head_comparison.rb +0 -139
  61. data/lib/ace/assign/authority/candidate_transfer.rb +0 -357
  62. data/lib/ace/assign/authority/cleanup_result_protection.rb +0 -53
  63. data/lib/ace/assign/authority/client.rb +0 -257
  64. data/lib/ace/assign/authority/command_execution.rb +0 -180
  65. data/lib/ace/assign/authority/deployment.rb +0 -627
  66. data/lib/ace/assign/authority/deployment_history.rb +0 -146
  67. data/lib/ace/assign/authority/endcap.rb +0 -457
  68. data/lib/ace/assign/authority/endcap_attempts.rb +0 -274
  69. data/lib/ace/assign/authority/endcap_campaign_results.rb +0 -232
  70. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +0 -266
  71. data/lib/ace/assign/authority/endcap_contexts.rb +0 -23
  72. data/lib/ace/assign/authority/endcap_inboxes.rb +0 -37
  73. data/lib/ace/assign/authority/endcap_prepared_work.rb +0 -80
  74. data/lib/ace/assign/authority/endcap_publication.rb +0 -41
  75. data/lib/ace/assign/authority/endcap_results.rb +0 -398
  76. data/lib/ace/assign/authority/endcap_review_requests.rb +0 -183
  77. data/lib/ace/assign/authority/endcap_reviews.rb +0 -130
  78. data/lib/ace/assign/authority/endcap_service_context.rb +0 -38
  79. data/lib/ace/assign/authority/endcap_service_settlement.rb +0 -152
  80. data/lib/ace/assign/authority/evidence_transfer.rb +0 -67
  81. data/lib/ace/assign/authority/execution_scope_observation.rb +0 -903
  82. data/lib/ace/assign/authority/fixed_publication_lifecycle.rb +0 -176
  83. data/lib/ace/assign/authority/fixed_service_lifecycle.rb +0 -594
  84. data/lib/ace/assign/authority/inbox_context_original.rb +0 -113
  85. data/lib/ace/assign/authority/launch_attempt_consumers.rb +0 -51
  86. data/lib/ace/assign/authority/launch_campaign_registration.rb +0 -214
  87. data/lib/ace/assign/authority/launch_control_channel.rb +0 -313
  88. data/lib/ace/assign/authority/launch_control_exclusions.rb +0 -127
  89. data/lib/ace/assign/authority/launch_driver.rb +0 -393
  90. data/lib/ace/assign/authority/launch_input_inhibition.rb +0 -132
  91. data/lib/ace/assign/authority/launch_lifecycle.rb +0 -997
  92. data/lib/ace/assign/authority/launch_prepared_work.rb +0 -122
  93. data/lib/ace/assign/authority/launch_review.rb +0 -91
  94. data/lib/ace/assign/authority/launch_scope_abort.rb +0 -81
  95. data/lib/ace/assign/authority/launch_scope_admission.rb +0 -215
  96. data/lib/ace/assign/authority/launch_scope_close.rb +0 -146
  97. data/lib/ace/assign/authority/launch_scope_parent.rb +0 -44
  98. data/lib/ace/assign/authority/launch_scope_release.rb +0 -859
  99. data/lib/ace/assign/authority/launch_steering.rb +0 -353
  100. data/lib/ace/assign/authority/launch_stop.rb +0 -254
  101. data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +0 -229
  102. data/lib/ace/assign/authority/ordinary_attempt_start.rb +0 -137
  103. data/lib/ace/assign/authority/ordinary_client.rb +0 -70
  104. data/lib/ace/assign/authority/ordinary_completion.rb +0 -104
  105. data/lib/ace/assign/authority/ordinary_effect_settlement.rb +0 -118
  106. data/lib/ace/assign/authority/ordinary_lifecycle.rb +0 -227
  107. data/lib/ace/assign/authority/ordinary_merge_evidence.rb +0 -81
  108. data/lib/ace/assign/authority/ordinary_peer.rb +0 -54
  109. data/lib/ace/assign/authority/ordinary_progress.rb +0 -162
  110. data/lib/ace/assign/authority/ordinary_queue.rb +0 -115
  111. data/lib/ace/assign/authority/ordinary_release_artifacts.rb +0 -143
  112. data/lib/ace/assign/authority/ordinary_results.rb +0 -206
  113. data/lib/ace/assign/authority/ordinary_review_evidence.rb +0 -180
  114. data/lib/ace/assign/authority/ordinary_reviews.rb +0 -214
  115. data/lib/ace/assign/authority/ordinary_server.rb +0 -75
  116. data/lib/ace/assign/authority/ordinary_supervisor.rb +0 -99
  117. data/lib/ace/assign/authority/original_inbox_selection.rb +0 -41
  118. data/lib/ace/assign/authority/posix_acl.rb +0 -59
  119. data/lib/ace/assign/authority/prepared_input.rb +0 -130
  120. data/lib/ace/assign/authority/prepared_queue.rb +0 -185
  121. data/lib/ace/assign/authority/prepared_task_context.rb +0 -116
  122. data/lib/ace/assign/authority/prepared_work.rb +0 -309
  123. data/lib/ace/assign/authority/prepared_worker.rb +0 -69
  124. data/lib/ace/assign/authority/private_directory.rb +0 -33
  125. data/lib/ace/assign/authority/protected_assignment_context.rb +0 -226
  126. data/lib/ace/assign/authority/readiness_hook.rb +0 -120
  127. data/lib/ace/assign/authority/receipt_transfer.rb +0 -48
  128. data/lib/ace/assign/authority/release_artifact_manifest.rb +0 -91
  129. data/lib/ace/assign/authority/retained_input.rb +0 -53
  130. data/lib/ace/assign/authority/router.rb +0 -81
  131. data/lib/ace/assign/authority/server.rb +0 -131
  132. data/lib/ace/assign/authority/service_cleanup_dispatch.rb +0 -88
  133. data/lib/ace/assign/authority/service_cleanup_evidence.rb +0 -180
  134. data/lib/ace/assign/authority/service_delivery_evidence.rb +0 -101
  135. data/lib/ace/assign/authority/service_evidence.rb +0 -286
  136. data/lib/ace/assign/authority/service_publication_evidence.rb +0 -110
  137. data/lib/ace/assign/authority/socket_server.rb +0 -255
  138. data/lib/ace/assign/authority/task_context_entry.rb +0 -32
  139. data/lib/ace/assign/authority/transfer_codec.rb +0 -189
  140. data/lib/ace/assign/cli/commands/attempt/base.rb +0 -84
  141. data/lib/ace/assign/cli/commands/attempt/evidence.rb +0 -28
  142. data/lib/ace/assign/cli/commands/attempt/finish.rb +0 -51
  143. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +0 -49
  144. data/lib/ace/assign/cli/commands/attempt/start.rb +0 -45
  145. data/lib/ace/assign/cli/commands/attempt/status.rb +0 -54
  146. data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +0 -25
  147. data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +0 -28
  148. data/lib/ace/assign/cli/commands/authority/launch.rb +0 -97
  149. data/lib/ace/assign/cli/commands/authority/serve.rb +0 -31
  150. data/lib/ace/assign/cli/commands/authority/status.rb +0 -26
  151. data/lib/ace/assign/cli/commands/authority/task_context.rb +0 -31
  152. data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +0 -25
  153. data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +0 -30
  154. data/lib/ace/assign/cli/commands/authority/terminate.rb +0 -29
  155. data/lib/ace/assign/cli/commands/authority/worker.rb +0 -23
  156. data/lib/ace/assign/cli/commands/campaign_export_result.rb +0 -55
  157. data/lib/ace/assign/cli/commands/campaign_record_round.rb +0 -57
  158. data/lib/ace/assign/cli/commands/delivery.rb +0 -107
  159. data/lib/ace/assign/cli/commands/inbox_bind.rb +0 -34
  160. data/lib/ace/assign/cli/commands/protected_submission.rb +0 -61
  161. data/lib/ace/assign/cli/commands/resume.rb +0 -38
  162. data/lib/ace/assign/cli/commands/submit_candidate.rb +0 -34
  163. data/lib/ace/assign/cli/commands/submit_result.rb +0 -61
  164. data/lib/ace/assign/models/attempt.rb +0 -178
  165. data/lib/ace/assign/models/attempt_binding.rb +0 -116
  166. data/lib/ace/assign/models/evidence_event.rb +0 -86
  167. data/lib/ace/assign/models/execution_receipt.rb +0 -139
  168. data/lib/ace/assign/molecules/attempt_reconciler.rb +0 -127
  169. data/lib/ace/assign/molecules/attempt_store.rb +0 -201
  170. data/lib/ace/assign/molecules/canonical_attempt_state.rb +0 -151
  171. data/lib/ace/assign/molecules/canonical_evidence.rb +0 -170
  172. data/lib/ace/assign/molecules/canonical_read_commands.rb +0 -84
  173. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +0 -313
  174. data/lib/ace/assign/molecules/evidence_calculator.rb +0 -237
  175. data/lib/ace/assign/molecules/evidence_journal.rb +0 -1899
  176. data/lib/ace/assign/molecules/execution_identity_resolver.rb +0 -137
  177. data/lib/ace/assign/molecules/execution_scope_lineage.rb +0 -345
  178. data/lib/ace/assign/molecules/fixed_effect_reference.rb +0 -31
  179. data/lib/ace/assign/molecules/installed_journal_read.rb +0 -143
  180. data/lib/ace/assign/molecules/journal_input_inhibition.rb +0 -78
  181. data/lib/ace/assign/molecules/journal_mutation.rb +0 -274
  182. data/lib/ace/assign/molecules/journal_prompt_mutation.rb +0 -241
  183. data/lib/ace/assign/molecules/ordinary_merge_cli.rb +0 -68
  184. data/lib/ace/assign/molecules/ordinary_merge_contract.rb +0 -410
  185. data/lib/ace/assign/molecules/original_launch_binding.rb +0 -37
  186. data/lib/ace/assign/molecules/proposal_journal.rb +0 -179
  187. data/lib/ace/assign/molecules/protected_control_exclusion.rb +0 -303
  188. data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +0 -330
  189. data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +0 -164
  190. data/lib/ace/assign/molecules/receipt_verifier.rb +0 -320
  191. data/lib/ace/assign/molecules/terminal_scope_receipt.rb +0 -88
  192. data/lib/ace/assign/organisms/attempt_coordinator.rb +0 -1374
  193. data/lib/ace/assign/organisms/delivery_coordinator.rb +0 -342
  194. data/lib/ace/assign/organisms/ordinary_driver.rb +0 -310
  195. data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +0 -365
  196. data/lib/ace/assign/organisms/prepared_work_builder.rb +0 -194
  197. data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +0 -100
@@ -1,139 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Ace
4
- module Assign
5
- module Authority
6
- # Compare retained Git objects in a private bare repository. Historical
7
- # links are object bytes, never paths to materialize or follow. This view
8
- # supplies evidence; it does not classify a change or grant acceptance.
9
- # Host: CandidateTransfer supplies bounded Git/admission, byte encoding,
10
- # private-root storage, monotonic deadlines and rejection helpers.
11
- module CandidateHeadComparison
12
- class ReadView
13
- def initialize(owner:, repository:, reviewed:, candidate:, deadline:)
14
- @owner, @repository, @reviewed, @candidate, @deadline = owner, repository, reviewed, candidate, deadline
15
- @thread, @active = Thread.current, true
16
- end
17
-
18
- def close = @active = false
19
-
20
- def context
21
- unless @active && Thread.current.equal?(@thread)
22
- raise AttemptErrors::EvidenceUnavailable, "head comparison escaped its source lifetime"
23
- end
24
- @owner.send(:head_comparison_context, @repository, @deadline, @reviewed, @candidate)
25
- rescue Timeout::Error
26
- raise AttemptErrors::EvidenceUnavailable, "head comparison deadline expired"
27
- end
28
- end
29
-
30
- def with_head_comparison(before:, after:, before_base:, after_base:)
31
- raise ArgumentError, "head comparison requires a block" unless block_given?
32
- [before, after].each do |input|
33
- unless input.is_a?(Hash) && input.keys.sort == %i[bytes head sha256 size tree] &&
34
- input[:tree].is_a?(String) && input[:tree].match?(self.class::SHA)
35
- reject!("Head comparison candidate binding is invalid")
36
- end
37
- end
38
- [before_base, after_base].each do |base|
39
- reject!("Head comparison base is invalid") unless base.is_a?(String) && base.match?(self.class::SHA)
40
- end
41
- deadline = monotonic + self.class::DEADLINE
42
- # Each complete bundle is independently verified against an empty
43
- # repository before either can contribute objects to the comparison.
44
- admit(**before.reject { |key, _| key == :tree }, deadline: deadline) do |_old_repo, _old_deadline, reviewed|
45
- reject!("Reviewed tree differs from accepted tree") unless reviewed.fetch("tree") == before[:tree]
46
- admit(**after.reject { |key, _| key == :tree }, deadline: deadline) do |repository, _new_deadline, candidate|
47
- reject!("Candidate tree differs from accepted tree") unless candidate.fetch("tree") == after[:tree]
48
- Tempfile.create(["reviewed-", ".bundle"], @root) do |file|
49
- file.binmode
50
- file.write(reviewed.fetch("bundle")); file.flush
51
- git(repository, deadline, "fetch", "--no-tags", file.path, "#{before[:head]}:refs/heads/reviewed")
52
- reader = ReadView.new(owner: self, repository: repository,
53
- reviewed: {"head" => before[:head], "base" => before_base, "tree" => before[:tree]},
54
- candidate: {"head" => after[:head], "base" => after_base, "tree" => after[:tree]}, deadline: deadline)
55
- yield reader
56
- ensure
57
- reader&.close
58
- end
59
- end
60
- end
61
- end
62
-
63
- private
64
-
65
- def head_comparison_context(repository, deadline, reviewed, candidate)
66
- context = {"schema" => "ace.candidate-head-comparison/v1", "reviewed" => reviewed,
67
- "candidate" => candidate, "inventories" => {}, "objects" => {}, "changes" => {}}
68
- remaining = self.class::MAX_REVIEW_BYTES
69
- {"reviewed_head" => reviewed["head"], "reviewed_base" => reviewed["base"],
70
- "candidate_head" => candidate["head"], "candidate_base" => candidate["base"]}.each do |label, commit|
71
- git(repository, deadline, "cat-file", "-e", "#{commit}^{commit}")
72
- tree = git(repository, deadline, "rev-parse", "#{commit}^{tree}").strip
73
- inventory = head_comparison_inventory(repository, deadline, tree)
74
- remaining -= JSON.generate(inventory).bytesize
75
- reject!("Complete head comparison context is oversized") if remaining.negative?
76
- context.fetch("inventories")[label] = {"tree" => tree, "entries" => inventory}
77
- end
78
- oids = context.fetch("inventories").values.flat_map { |inventory| inventory.fetch("entries").map { |entry| entry.fetch("oid") } }.uniq
79
- sizes = oids.zip(candidate_blob_sizes(repository, deadline, oids)).to_h
80
- reject!("Complete head comparison object content is oversized") if sizes.values.sum > remaining
81
- each_candidate_blob(repository, deadline, sizes.to_a) do |oid, content|
82
- object = {"size" => content.bytesize, "sha256" => Digest::SHA256.hexdigest(content), "content" => review_bytes(content)}
83
- remaining -= JSON.generate(object).bytesize
84
- reject!("Complete head comparison context is oversized") if remaining.negative?
85
- context.fetch("objects")[oid] = object
86
- end
87
- {"candidate_delta" => [reviewed["head"], candidate["head"]],
88
- "base_delta" => [reviewed["base"], candidate["base"]],
89
- "original_scope" => [reviewed["base"], reviewed["head"]],
90
- "current_scope" => [candidate["base"], candidate["head"]]}.each do |label, revisions|
91
- delta = head_comparison_delta(repository, deadline, revisions, remaining)
92
- remaining -= JSON.generate(delta).bytesize
93
- reject!("Complete head comparison context is oversized") if remaining.negative?
94
- context.fetch("changes")[label] = delta
95
- end
96
- # Include structural JSON overhead in the final bound as well as all
97
- # original bytes. No partial inventory or truncated patch is returned.
98
- reject!("Complete head comparison context is oversized") if JSON.generate(context).bytesize > self.class::MAX_REVIEW_BYTES
99
- context
100
- rescue AttemptErrors::ReceiptRejected => error
101
- reject!("Head comparison read failed: #{error.message}")
102
- end
103
-
104
- def head_comparison_inventory(repository, deadline, tree)
105
- entries = git(repository, deadline, "ls-tree", "-r", "-z", tree, limit: self.class::MAX_BYTES).b.split("\0")
106
- reject!("Complete head comparison inventory is oversized") if entries.size > self.class::MAX_OBJECTS
107
- entries.map do |entry|
108
- metadata, path = entry.split("\t", 2)
109
- mode, kind, oid = metadata.to_s.split(" ")
110
- # Inventory entries carry whole paths only; bound their bytes
111
- # before JSON encoding or delta cross-checks consume them.
112
- reject!("Complete head comparison path exceeds bounded length") unless path && !path.empty? &&
113
- path.bytesize <= Atoms::CandidateLimits::PATH_BYTES
114
- unless kind == "blob" && %w[100644 100755 120000].include?(mode) && oid&.match?(self.class::SHA)
115
- reject!("Complete head comparison contains an unsupported Git entry")
116
- end
117
- {"path" => review_bytes(path), "mode" => mode, "oid" => oid}
118
- end
119
- end
120
-
121
- def head_comparison_delta(repository, deadline, revisions, remaining)
122
- options = ["--no-ext-diff", "--no-textconv", "--no-renames", "--ignore-submodules=none"]
123
- patch = git(repository, deadline, "diff", *options, "--binary", "--full-index", "--src-prefix=a/", "--dst-prefix=b/",
124
- *revisions, "--", limit: [remaining, 1].max).b
125
- paths = git(repository, deadline, "diff", *options, "--name-only", "-z", *revisions, "--", limit: [remaining, 1].max).b.split("\0")
126
- stats = git(repository, deadline, "diff", *options, "--numstat", "-z", *revisions, "--", limit: [remaining, 1].max).b.split("\0")
127
- binary = stats.filter_map do |entry|
128
- added, removed, path = entry.split("\t", 3)
129
- reject!("Complete head comparison diff inventory is invalid") unless path && paths.include?(path)
130
- path if added == "-" || removed == "-"
131
- end
132
- {"from" => revisions.first, "to" => revisions.last, "patch" => review_bytes(patch),
133
- "patch_sha256" => Digest::SHA256.hexdigest(patch), "paths" => paths.map { |path| review_bytes(path) },
134
- "binary_paths" => binary.map { |path| review_bytes(path) }}
135
- end
136
- end
137
- end
138
- end
139
- end
@@ -1,357 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "digest"
4
- require "json"
5
- require "fileutils"
6
- require "tmpdir"
7
- require "tempfile"
8
- require "ace/herdr/molecules/bounded_process"
9
- require "ace/review/atoms/campaign_contract"
10
- require_relative "private_directory"
11
- require_relative "../atoms/candidate_limits"
12
- require_relative "candidate_head_comparison"
13
- require_relative "candidate_blob_batch"
14
-
15
- module Ace
16
- module Assign
17
- module Authority
18
- # Untrusted bundles enter a clean bare quarantine. Only complete verified
19
- # objects are retained; no uploader configuration or worktree is read.
20
- class CandidateTransfer
21
- include CandidateHeadComparison
22
- include CandidateBlobBatch
23
- MAX_BYTES = Atoms::CandidateLimits::BYTES
24
- MAX_REVIEW_BYTES = Ace::Review::Atoms::CampaignContract::MAX_HEAD_CONTEXT_BYTES
25
- # Measured on the real 82,809,675-byte candidate (9,203 entries,
26
- # 71,583,200 expanded bytes): two full admits alone need ~14s of
27
- # bounded Git work and the complete comparison ~23s on a loaded host
28
- # (evidence in .ace-tasks/8xf.t.ei9-wave-3 evidence). 30s failed
29
- # closed under legitimate load; 60s keeps a finite secondary guard
30
- # with measured headroom. Inputs stay byte/count-bounded separately.
31
- DEADLINE = 60
32
- SHA = /\A[0-9a-f]{40}\z/
33
-
34
- class CampaignReadView
35
- def initialize(owner:, repository:, base:, deadline:)
36
- @owner, @repository, @base, @deadline = owner, repository, base, deadline
37
- @thread, @active = Thread.current, true
38
- end
39
-
40
- def close = @active = false
41
- def head = command("rev-parse", "HEAD").strip
42
- def base
43
- commit!(@base)
44
- @base
45
- end
46
- def commit!(revision)
47
- raise ArgumentError, "campaign revision is invalid" unless revision.is_a?(String) && revision.match?(SHA)
48
- command("cat-file", "-e", "#{revision}^{commit}")
49
- true
50
- end
51
- def full_diff?(base, head)
52
- commit!(base)
53
- commit!(head)
54
- !command("diff", "--raw", "--no-ext-diff", "--no-textconv", base, head, "--").empty?
55
- end
56
- def clean?
57
- command("status", "--porcelain", "-z", "--untracked-files=all").split("\0")
58
- .all? { |entry| entry.start_with?("?? .ace-local/") }
59
- end
60
-
61
- private
62
-
63
- def command(*arguments)
64
- unless @active && Thread.current.equal?(@thread)
65
- raise AttemptErrors::EvidenceUnavailable, "campaign Git read view escaped its source lifetime"
66
- end
67
- @owner.send(:git, @repository, @deadline, *arguments)
68
- rescue Timeout::Error
69
- raise AttemptErrors::EvidenceUnavailable, "campaign Git read deadline expired"
70
- end
71
- end
72
-
73
- def with_campaign_repository(bytes:, sha256:, size:, head:, tree:, base:)
74
- raise ArgumentError, "campaign repository requires a block" unless block_given?
75
- deadline = monotonic + DEADLINE
76
- materialized = materialize_repository(bytes: bytes, sha256: sha256, size: size, head: head, tree: tree, root: @root)
77
- directory = materialized.fetch("directory")
78
- reader = CampaignReadView.new(owner: self, repository: directory, base: base, deadline: deadline)
79
- unless reader.head == head
80
- raise AttemptErrors::EvidenceUnavailable, "campaign Git head differs from original candidate"
81
- end
82
- reader.base
83
- yield reader
84
- ensure
85
- reader&.close
86
- FileUtils.remove_entry(directory) if directory && File.directory?(directory)
87
- end
88
-
89
- def initialize(root:)
90
- @root = File.expand_path(root)
91
- verify_root!
92
- end
93
-
94
- # Export the already validated prepared tree using the same isolated Git
95
- # boundary as admission. The returned bytes are retained by the producer;
96
- # registration must not rebuild them after a lost response.
97
- def export_prepared(work:)
98
- require_relative "prepared_work"
99
- reject!("Prepared export requires validated work") unless work.is_a?(PreparedWork)
100
- verify_root!
101
- deadline = monotonic + DEADLINE
102
- Dir.mktmpdir("prepared-export-", @root) do |directory|
103
- File.chmod(0700, directory)
104
- repository = File.join(directory, "repository")
105
- git(directory, deadline, "init", "--template=", "--initial-branch=prepared", repository)
106
- work.files.each do |path, bytes|
107
- target = File.join(repository, path)
108
- FileUtils.mkdir_p(File.dirname(target), mode: 0700)
109
- File.open(target, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0600) do |file|
110
- file.write(bytes)
111
- end
112
- end
113
- git(repository, deadline, "add", "--", ".")
114
- git(repository, deadline, "-c", "user.name=ACE prepared input", "-c", "user.email=prepared@ace.invalid",
115
- "-c", "commit.gpgSign=false", "commit", "--no-verify", "-m", "Prepared assignment input")
116
- head = git(repository, deadline, "rev-parse", "HEAD").strip
117
- tree = git(repository, deadline, "rev-parse", "HEAD^{tree}").strip
118
- bundle = File.join(directory, "prepared.bundle")
119
- git(repository, deadline, "bundle", "create", bundle, "refs/heads/prepared")
120
- bytes = read_bundle(bundle).freeze
121
- {"head" => head.freeze, "tree" => tree.freeze, "bundle" => bytes,
122
- "bytes" => bytes.bytesize, "sha256" => Digest::SHA256.hexdigest(bytes).freeze,
123
- "definition_bytes" => work.definition_bytes(head: head, tree: tree)}.freeze
124
- end
125
- rescue Timeout::Error
126
- reject!("Prepared export deadline exceeded")
127
- rescue SystemCallError
128
- reject!("Prepared export filesystem is unavailable")
129
- end
130
-
131
- def admit(bytes:, sha256:, size:, head:, deadline: nil, &consumer)
132
- unless bytes.is_a?(String) && size.is_a?(Integer) && size.positive? && size <= MAX_BYTES &&
133
- size == bytes.bytesize && sha256.is_a?(String) &&
134
- Digest::SHA256.hexdigest(bytes.b) == sha256 && head.is_a?(String) && head.match?(SHA)
135
- reject!("Candidate bundle binding or size is invalid")
136
- end
137
- verify_root!
138
- deadline = deadline ? [deadline, monotonic + DEADLINE].min : monotonic + DEADLINE
139
- Dir.mktmpdir("quarantine-", @root) do |directory|
140
- File.chmod(0700, directory)
141
- bundle = File.join(directory, "candidate.bundle")
142
- File.open(bundle, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0600) { |f| f.write(bytes.b) }
143
- repository = File.join(directory, "repository")
144
- git(directory, deadline, "init", "--bare", "--template=", repository)
145
- # Verification against an empty repository rejects incremental
146
- # bundles requiring any caller-supplied prerequisites.
147
- git(repository, deadline, "bundle", "verify", bundle)
148
- advertised = git(repository, deadline, "bundle", "list-heads", bundle).lines.map { |line| line.split.first }
149
- reject!("Candidate head is not advertised") unless advertised.include?(head)
150
- git(repository, deadline, "fetch", "--no-tags", bundle, "#{head}:refs/heads/candidate")
151
- actual = git(repository, deadline, "rev-parse", "refs/heads/candidate^{commit}").strip
152
- reject!("Candidate commit differs from declared head") unless actual == head
153
- git(repository, deadline, "fsck", "--full", "--strict", "--no-reflogs")
154
- tree = git(repository, deadline, "rev-parse", "#{head}^{tree}").strip
155
- exported = File.join(directory, "verified.bundle")
156
- git(repository, deadline, "bundle", "create", exported, "refs/heads/candidate")
157
- verified = read_bundle(exported)
158
- # The journal owns candidate identity/generation. This object is
159
- # only immutable transport bytes, not another authority ledger.
160
- result = {"head" => head, "tree" => tree, "bundle" => verified,
161
- "sha256" => Digest::SHA256.hexdigest(verified), "bytes" => verified.bytesize}
162
- consumer ? consumer.call(repository, deadline, result) : result
163
- end
164
- rescue Timeout::Error
165
- reject!("Candidate transfer deadline exceeded")
166
- rescue SystemCallError
167
- reject!("Candidate transfer filesystem is unavailable")
168
- end
169
-
170
- # root is selected from installed peer credentials by the receiver,
171
- # never a request field. Materialization reads objects rather than Git
172
- # checkout, so .gitattributes, filters and uploader hooks cannot run.
173
- def materialize(bytes:, sha256:, size:, head:, tree:, root:)
174
- self.class.new(root: root)
175
- admit(bytes: bytes, sha256: sha256, size: size, head: head) do |repository, deadline, result|
176
- reject!("Candidate tree differs from accepted tree") unless result["tree"] == tree
177
- destination = Dir.mktmpdir("candidate-", root)
178
- File.chmod(0700, destination)
179
- begin
180
- entries = git(repository, deadline, "ls-tree", "-r", "-z", head, limit: MAX_BYTES).b.split("\0")
181
- reject!("Candidate file inventory is oversized") if entries.size > MAX_OBJECTS
182
- parsed = entries.map do |entry|
183
- metadata, path = entry.split("\t", 2)
184
- mode, kind, oid = metadata.to_s.split(" ")
185
- # Total path bytes are bounded before any component or
186
- # filesystem prefix work consumes them.
187
- reject!("Candidate path exceeds bounded length") unless path && !path.start_with?("/") &&
188
- path.bytesize <= Atoms::CandidateLimits::PATH_BYTES
189
- parts = path.split("/", -1)
190
- unless parts.none? { |part| part.empty? || part.bytesize > Atoms::CandidateLimits::PATH_COMPONENT_BYTES ||
191
- %w[. .. .git].include?(part.downcase) } &&
192
- kind == "blob" && %w[100644 100755 120000].include?(mode) && oid&.match?(SHA)
193
- reject!("Candidate contains unsafe filesystem entry")
194
- end
195
- [path, mode, oid]
196
- end
197
- by_oid = parsed.group_by(&:last)
198
- sizes = by_oid.keys.zip(candidate_blob_sizes(repository, deadline, by_oid.keys)).to_h
199
- reject!("Candidate expanded content is oversized") if parsed.sum { |_path, _mode, oid| sizes.fetch(oid) } > MAX_BYTES
200
- links = []
201
- ordered = parsed.each
202
- each_candidate_blob(repository, deadline, parsed.map { |_path, _mode, oid| [oid, sizes.fetch(oid)] }) do |oid, content|
203
- path, mode, _oid = ordered.next
204
- target = File.join(destination, path)
205
- FileUtils.mkdir_p(File.dirname(target), mode: 0700)
206
- if mode == "120000"
207
- resolved = File.expand_path(content, File.dirname(target))
208
- unless !content.include?("\0") && !content.start_with?("/") &&
209
- resolved.start_with?(destination + "/")
210
- reject!("Candidate symlink escapes private checkout")
211
- end
212
- links << [target, content]
213
- else
214
- File.open(target, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW,
215
- mode == "100755" ? 0700 : 0600) { |file| file.write(content) }
216
- end
217
- yield(path.freeze, mode.freeze, oid.freeze, content.freeze) if block_given?
218
- end
219
- links.each { |target, content| File.symlink(content, target) }
220
- links.each do |target, _content|
221
- resolved = File.realpath(target)
222
- reject!("Candidate symlink escapes private checkout") unless resolved.start_with?(destination + "/")
223
- end
224
- result.reject { |key, _value| key == "bundle" }.merge("directory" => destination)
225
- rescue Exception
226
- # Only this freshly created private disposable directory is
227
- # removed. An existing peer scratch tree is never cleaned.
228
- FileUtils.rm_rf(destination)
229
- raise
230
- end
231
- end
232
- rescue SystemCallError, ArgumentError
233
- reject!("Candidate filesystem materialization is unverifiable")
234
- end
235
-
236
- # Executing campaign children need local Git history as well as files.
237
- # Create only our own empty administration; no uploader configuration,
238
- # checkout filters or hooks enter this private repository.
239
- def materialize_repository(bytes:, sha256:, size:, head:, tree:, root:)
240
- deadline = monotonic + DEADLINE
241
- result = materialize(bytes: bytes, sha256: sha256, size: size, head: head, tree: tree, root: root)
242
- directory = result.fetch("directory")
243
- Tempfile.create(["campaign-candidate-", ".bundle"], root) do |file|
244
- file.binmode
245
- file.write(bytes); file.flush
246
- git(directory, deadline, "init", "--template=", "--initial-branch=work")
247
- git(directory, deadline, "fetch", "--no-tags", file.path, "#{head}:refs/heads/candidate")
248
- git(directory, deadline, "symbolic-ref", "HEAD", "refs/heads/candidate")
249
- git(directory, deadline, "read-tree", head)
250
- reject!("Campaign private repository differs") unless git(directory, deadline, "rev-parse", "HEAD^{tree}").strip == tree
251
- end
252
- result
253
- rescue Exception
254
- FileUtils.remove_entry(directory) if directory && File.directory?(directory) && !File.symlink?(directory)
255
- raise
256
- end
257
-
258
- # Full immutable review input comes from the same admitted Git objects
259
- # used for materialization, never a second read of the scratch checkout.
260
- def review_snapshot(bytes:, sha256:, size:, head:, tree:, root:)
261
- subject = JSON.generate("schema" => "ace.candidate-review-snapshot/v1", "head" => head, "tree" => tree)
262
- .delete_suffix("}") + ',"files":['
263
- first = true
264
- result = materialize(bytes: bytes, sha256: sha256, size: size, head: head, tree: tree, root: root) do |path, mode, oid, content|
265
- entry = JSON.generate("path" => review_bytes(path), "mode" => mode, "oid" => oid, "content" => review_bytes(content))
266
- separator = first ? "" : ","
267
- reject!("Complete candidate review snapshot is oversized") if subject.bytesize + separator.bytesize + entry.bytesize + 2 > Atoms::CandidateLimits::REVIEW_SNAPSHOT_BYTES
268
- subject << separator << entry
269
- first = false
270
- end
271
- subject << "]}"
272
- result.merge("subject" => subject.freeze)
273
- end
274
-
275
- # Prepared instructions have a narrower tree contract than candidates:
276
- # no executable entries or symlinks, and bounded UTF-8 files. Read only
277
- # verified Git objects; never run checkout filters or reopen uploader paths.
278
- def prepared_files(repository:, deadline:, head:)
279
- entries = git(repository, deadline, "ls-tree", "-r", "-z", head, limit: MAX_BYTES).b.split("\0")
280
- reject!("Prepared file inventory is oversized") unless entries.size.between?(4, 4096)
281
- parsed = entries.map do |entry|
282
- metadata, path = entry.split("\t", 2)
283
- mode, kind, oid = metadata.to_s.split(" ")
284
- reject!("Prepared tree path exceeds bounded length") unless path && !path.start_with?("/") &&
285
- path.bytesize <= Atoms::CandidateLimits::PATH_BYTES
286
- unless mode == "100644" && kind == "blob" && oid&.match?(SHA) &&
287
- path.split("/", -1).none? { |part| part.empty? || part.bytesize > Atoms::CandidateLimits::PATH_COMPONENT_BYTES ||
288
- %w[. .. .git].include?(part.downcase) }
289
- reject!("Prepared tree contains unsafe entry")
290
- end
291
- [path, oid]
292
- end
293
- by_oid = parsed.group_by(&:last)
294
- sizes = by_oid.keys.zip(candidate_blob_sizes(repository, deadline, by_oid.keys)).to_h
295
- reject!("Prepared text is oversized") unless sizes.values.all? { |size| size.between?(1, 1024 * 1024) }
296
- reject!("Prepared expanded content is oversized") if parsed.sum { |_path, oid| sizes.fetch(oid) } > Atoms::CandidateLimits::PREPARED_BYTES
297
- files = {}
298
- ordered = parsed.each
299
- each_candidate_blob(repository, deadline, parsed.map { |_path, oid| [oid, sizes.fetch(oid)] }) do |_oid, content|
300
- files[ordered.next.first] = content
301
- end
302
- reject!("Prepared inventory has duplicate paths") unless files.size == entries.size
303
- files
304
- end
305
-
306
- private
307
-
308
- def review_bytes(bytes)
309
- text = bytes.dup.force_encoding(Encoding::UTF_8)
310
- if text.valid_encoding? && !text.include?("\0")
311
- {"encoding" => "utf-8", "bytes" => text}
312
- else
313
- {"encoding" => "base64", "bytes" => [bytes].pack("m0")}
314
- end
315
- end
316
-
317
- def verify_root!
318
- PrivateDirectory.verify!(@root)
319
- end
320
-
321
- def git(repository, deadline, *arguments, limit: 65_536, stdin_data: "")
322
- remaining = deadline - monotonic
323
- raise Timeout::Error if remaining <= 0
324
- # Git variables may redirect object/config storage even in a clean
325
- # repository. Strip every inherited Git setting and global config.
326
- environment = ENV.keys.to_h { |key| [key, nil] }
327
- environment.merge!("GIT_CONFIG_NOSYSTEM" => "1", "GIT_CONFIG_GLOBAL" => "/dev/null",
328
- "GIT_ATTR_NOSYSTEM" => "1",
329
- "GIT_TERMINAL_PROMPT" => "0", "GIT_ALLOW_PROTOCOL" => "file",
330
- "GIT_NO_REPLACE_OBJECTS" => "1", "PATH" => "/usr/bin:/bin", "LC_ALL" => "C")
331
- result = Herdr::Molecules::BoundedProcess.call([environment, "/usr/bin/git", "-C", repository,
332
- "-c", "core.hooksPath=/dev/null", "-c", "core.attributesFile=/dev/null", "-c", "protocol.file.allow=always", *arguments],
333
- stdin_data: stdin_data, timeout_s: remaining, output_limit: limit)
334
- reject!("Candidate Git verification failed") unless result.status.success? && !result.oversized
335
- result.stdout
336
- end
337
-
338
- def read_bundle(path)
339
- File.open(path, File::RDONLY | File::NOFOLLOW) do |file|
340
- reject!("Verified bundle is oversized") unless file.stat.file? && file.stat.size <= MAX_BYTES
341
- bytes = file.read(MAX_BYTES + 1).b
342
- reject!("Verified bundle is oversized") if bytes.bytesize > MAX_BYTES
343
- bytes
344
- end
345
- end
346
-
347
- def monotonic
348
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
349
- end
350
-
351
- def reject!(message)
352
- raise AttemptErrors::ReceiptRejected, message
353
- end
354
- end
355
- end
356
- end
357
- end
@@ -1,53 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "fiddle"
4
- require "ace/runtime/molecules/protected_artifact_set"
5
-
6
- module Ace
7
- module Assign
8
- module Authority
9
- # Extends the existing held artifact protection solely for the bounded
10
- # root result view. Private archive storage is never admitted here.
11
- class CleanupResultProtection < Ace::Runtime::Molecules::ProtectedArtifactSet::Protection
12
- class LinuxACL
13
- def absent!(handle, attribute)
14
- unless RUBY_PLATFORM.include?("linux")
15
- raise Ace::Runtime::RuntimeUnavailableError, "cleanup result ACL evidence requires Linux"
16
- end
17
- function = Fiddle::Function.new(Fiddle::Handle::DEFAULT["fgetxattr"],
18
- [Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T], Fiddle::TYPE_SSIZE_T)
19
- size = function.call(handle.fileno, attribute, 0, 0)
20
- error = Fiddle.last_error
21
- unless size == -1 && error == Errno::ENODATA::Errno
22
- raise Ace::Runtime::RuntimeUnavailableError, "cleanup result ACL protection is unavailable"
23
- end
24
- rescue Fiddle::DLError
25
- raise Ace::Runtime::RuntimeUnavailableError, "cleanup result ACL reader is unavailable"
26
- end
27
- end
28
-
29
- def initialize(result_path:, authority_gid:, acl: LinuxACL.new, **options)
30
- unless result_path.is_a?(String) && result_path.start_with?("/") && !result_path.include?("\0") &&
31
- File.expand_path(result_path) == result_path && authority_gid.is_a?(Integer) && authority_gid.positive?
32
- raise ArgumentError, "invalid fixed cleanup result view"
33
- end
34
- super(**options)
35
- @result_path, @parent, @authority_gid, @acl = result_path.dup.freeze, File.dirname(result_path).freeze, authority_gid, acl
36
- end
37
-
38
- def verify!(path, handle, directory:)
39
- super
40
- return unless path == @result_path || path == @parent
41
- parent = path == @parent
42
- stat = handle.stat
43
- unless directory == parent && stat.gid == @authority_gid &&
44
- (stat.mode & 0o7777) == (parent ? 0o2750 : 0o640)
45
- raise Ace::Runtime::RuntimeUnavailableError, "cleanup result view mode or group differs"
46
- end
47
- @acl.absent!(handle, "system.posix_acl_access")
48
- @acl.absent!(handle, "system.posix_acl_default") if parent
49
- end
50
- end
51
- end
52
- end
53
- end