ace-assign 0.57.1 → 0.58.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.
- checksums.yaml +4 -4
- data/.ace-defaults/assign/config.yml +13 -0
- data/CHANGELOG.md +13 -0
- data/docs/exit-codes.md +10 -0
- data/docs/usage.md +32 -0
- data/handbook/workflow-instructions/assign/drive.wf.md +64 -8
- data/lib/ace/assign/atoms/assignment_scope.rb +53 -0
- data/lib/ace/assign/atoms/attempt_state_machine.rb +76 -0
- data/lib/ace/assign/atoms/evidence_digest.rb +78 -0
- data/lib/ace/assign/cli/commands/attempt/base.rb +35 -0
- data/lib/ace/assign/cli/commands/attempt/finish.rb +35 -0
- data/lib/ace/assign/cli/commands/attempt/reconcile.rb +38 -0
- data/lib/ace/assign/cli/commands/attempt/start.rb +45 -0
- data/lib/ace/assign/cli/commands/attempt/status.rb +54 -0
- data/lib/ace/assign/cli/commands/status.rb +24 -1
- data/lib/ace/assign/cli.rb +30 -2
- data/lib/ace/assign/models/assignment.rb +21 -4
- data/lib/ace/assign/models/attempt.rb +178 -0
- data/lib/ace/assign/models/attempt_binding.rb +116 -0
- data/lib/ace/assign/models/evidence_event.rb +82 -0
- data/lib/ace/assign/models/execution_receipt.rb +135 -0
- data/lib/ace/assign/molecules/assignment_manager.rb +35 -3
- data/lib/ace/assign/molecules/attempt_reconciler.rb +88 -0
- data/lib/ace/assign/molecules/attempt_store.rb +201 -0
- data/lib/ace/assign/molecules/evidence_calculator.rb +144 -91
- data/lib/ace/assign/molecules/evidence_journal.rb +424 -0
- data/lib/ace/assign/molecules/execution_identity_resolver.rb +97 -0
- data/lib/ace/assign/molecules/receipt_verifier.rb +227 -0
- data/lib/ace/assign/organisms/assignment_executor.rb +9 -3
- data/lib/ace/assign/organisms/attempt_coordinator.rb +544 -0
- data/lib/ace/assign/organisms/task_assignment_creator.rb +1 -1
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +56 -0
- metadata +20 -2
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Assign
|
|
7
|
+
module Molecules
|
|
8
|
+
# Canonical receipt validation for accepted execution evidence.
|
|
9
|
+
#
|
|
10
|
+
# Verifies schema and field hygiene, attempt binding, producer
|
|
11
|
+
# attribution, transition authority, current-head candidate binding,
|
|
12
|
+
# artifact digests, executed checks, and independent review verdicts.
|
|
13
|
+
# Every failure is a rejection with an actionable reason; a receipt is
|
|
14
|
+
# never partially accepted.
|
|
15
|
+
class ReceiptVerifier
|
|
16
|
+
# Operations that act outside the local repository once executed.
|
|
17
|
+
EXTERNAL_EFFECT_OPERATIONS = %w[merge publish deploy release].freeze
|
|
18
|
+
|
|
19
|
+
REVIEW_OPERATION = "review"
|
|
20
|
+
HEAD_PATTERN = /\A[0-9a-f]{7,64}\z/.freeze
|
|
21
|
+
APPROVED_VERDICT = "approved"
|
|
22
|
+
|
|
23
|
+
# @param identity_resolver [ExecutionIdentityResolver] Trust boundary
|
|
24
|
+
def initialize(identity_resolver: nil)
|
|
25
|
+
@identity_resolver = identity_resolver || ExecutionIdentityResolver.new
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Validate a submitted receipt against an attempt.
|
|
29
|
+
#
|
|
30
|
+
# @param data [Hash] Parsed receipt payload
|
|
31
|
+
# @param attempt [Models::Attempt] Target attempt (loaded by coordinator)
|
|
32
|
+
# @param identity [ExecutionIdentityResolver::Identity] Finishing boundary identity
|
|
33
|
+
# @param live_head [String] Current candidate revision (git rev-parse HEAD)
|
|
34
|
+
# @param repo_root [String] Project root for artifact resolution
|
|
35
|
+
# @return [Models::ExecutionReceipt] Normalized receipt with digest
|
|
36
|
+
# @raise [AttemptErrors::ReceiptRejected] on any validation failure
|
|
37
|
+
def verify!(data, attempt:, identity:, live_head:, repo_root:)
|
|
38
|
+
reject_unless(data.is_a?(Hash), "receipt must be a JSON object")
|
|
39
|
+
|
|
40
|
+
if Models::ExecutionReceipt.forbidden_field?(data)
|
|
41
|
+
reject("receipt carries forbidden fields (credentials, environment, or terminal output)")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
required = %w[attempt_id assignment_id project_id scope operation producer head verdict]
|
|
45
|
+
missing = required.select { |key| data[key].nil? || data[key].to_s.strip.empty? }
|
|
46
|
+
reject("missing required fields: #{missing.join(', ')}") unless missing.empty?
|
|
47
|
+
unless Models::ExecutionReceipt::VERDICTS.include?(data["verdict"].to_s)
|
|
48
|
+
reject("unsupported verdict '#{data['verdict']}': must be one of #{Models::ExecutionReceipt::VERDICTS.join(', ')}")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
verify_binding(data, attempt)
|
|
52
|
+
verify_producer(data)
|
|
53
|
+
verify_authority(data, identity)
|
|
54
|
+
verify_head(data, live_head)
|
|
55
|
+
verify_artifacts(data, repo_root) if data["verdict"] == "succeeded"
|
|
56
|
+
verify_checks(data)
|
|
57
|
+
verify_review(data)
|
|
58
|
+
|
|
59
|
+
receipt = Models::ExecutionReceipt.from_h(data.merge("recorded_at" => Time.now.utc))
|
|
60
|
+
verify_digest(receipt)
|
|
61
|
+
|
|
62
|
+
receipt
|
|
63
|
+
end
|
|
64
|
+
# @param operation [String] Receipt operation
|
|
65
|
+
# @return [Boolean] True when the operation acts outside the repository
|
|
66
|
+
def external_effect?(operation)
|
|
67
|
+
EXTERNAL_EFFECT_OPERATIONS.include?(operation.to_s)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def verify_binding(data, attempt)
|
|
73
|
+
binding = attempt.binding
|
|
74
|
+
unless data["attempt_id"] == attempt.attempt_id
|
|
75
|
+
reject("receipt attempt_id #{data['attempt_id']} does not match attempt #{attempt.attempt_id}")
|
|
76
|
+
end
|
|
77
|
+
unless data["assignment_id"] == binding.assignment_id
|
|
78
|
+
reject("receipt assignment #{data['assignment_id']} does not match attempt assignment #{binding.assignment_id}")
|
|
79
|
+
end
|
|
80
|
+
unless data["project_id"] == binding.project_id
|
|
81
|
+
reject("receipt project #{data['project_id']} does not match attempt project #{binding.project_id}")
|
|
82
|
+
end
|
|
83
|
+
unless Atoms::AssignmentScope.equal?(data["scope"], binding.scope)
|
|
84
|
+
reject("receipt scope #{data['scope']} does not match attempt scope #{binding.scope}")
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def verify_producer(data)
|
|
89
|
+
producer = data["producer"]
|
|
90
|
+
reject_unless(producer.is_a?(Hash), "producer must be an object with actor, role, runtime")
|
|
91
|
+
|
|
92
|
+
actor = producer["actor"].to_s.strip
|
|
93
|
+
role = producer["role"].to_s.strip
|
|
94
|
+
runtime = producer["runtime"].to_s.strip
|
|
95
|
+
if actor.empty? || runtime.empty? || !ExecutionIdentityResolver::ROLES.include?(role)
|
|
96
|
+
reject("producer attribution incomplete: actor, runtime, and a known role are required")
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Workers submit attributable results but cannot self-approve
|
|
101
|
+
# succeeded outcomes; only coordinator/service authority accepts them.
|
|
102
|
+
def verify_authority(data, identity)
|
|
103
|
+
return if data["verdict"] != "succeeded"
|
|
104
|
+
return if @identity_resolver.trusted?(identity)
|
|
105
|
+
|
|
106
|
+
reject("identity #{identity.actor}/#{identity.role} may not accept a succeeded verdict")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The tested/reviewed head must be the live candidate revision; stale
|
|
110
|
+
# heads are rejected (candidate invalidation is recorded by the
|
|
111
|
+
# coordinator).
|
|
112
|
+
def verify_head(data, live_head)
|
|
113
|
+
reject("live candidate head unavailable") if live_head.nil? || live_head.to_s.strip.empty?
|
|
114
|
+
reject("receipt head '#{data['head']}' is not a git SHA") unless data["head"].match?(HEAD_PATTERN)
|
|
115
|
+
|
|
116
|
+
unless data["head"] == live_head
|
|
117
|
+
reject("receipt head #{data['head']} is stale; current candidate is #{live_head}")
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Succeeded outcomes need at least one artifact whose recorded digest
|
|
122
|
+
# matches the file on disk. Paths stay below the project root; symlink
|
|
123
|
+
# or traversal escapes are rejected.
|
|
124
|
+
def verify_artifacts(data, repo_root)
|
|
125
|
+
artifacts = data["artifacts"]
|
|
126
|
+
reject_unless(artifacts.is_a?(Array) && !artifacts.empty?, "succeeded verdict requires at least one artifact")
|
|
127
|
+
|
|
128
|
+
artifacts.each do |artifact|
|
|
129
|
+
reject_unless(artifact.is_a?(Hash), "artifact entries must be objects")
|
|
130
|
+
path = artifact["path"].to_s
|
|
131
|
+
recorded = artifact["sha256"].to_s
|
|
132
|
+
reject("artifact missing path or sha256") if path.empty? || recorded.empty?
|
|
133
|
+
|
|
134
|
+
expanded = begin
|
|
135
|
+
File.expand_path(path, File.realpath(repo_root))
|
|
136
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
reject("artifact file not found: #{path}") if expanded.nil? || !File.exist?(expanded)
|
|
140
|
+
|
|
141
|
+
resolved = safe_resolve(repo_root, path)
|
|
142
|
+
reject("artifact path escapes project root: #{path}") if resolved.nil?
|
|
143
|
+
|
|
144
|
+
actual = Digest::SHA256.hexdigest(File.read(resolved))
|
|
145
|
+
reject("artifact digest mismatch for #{path}") unless actual == recorded
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def verify_checks(data)
|
|
150
|
+
checks = data["checks"] || []
|
|
151
|
+
reject_unless(checks.is_a?(Array), "checks must be an array")
|
|
152
|
+
if data["verdict"] == "succeeded" && checks.empty?
|
|
153
|
+
reject("succeeded verdict requires at least one executed check")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
checks.each do |check|
|
|
157
|
+
reject_unless(check.is_a?(Hash), "check entries must be objects")
|
|
158
|
+
reject("check missing name") if check["name"].to_s.strip.empty?
|
|
159
|
+
reject("check #{check['name']} did not pass") if check["verdict"].to_s != "passed" && data["verdict"] == "succeeded"
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# A caller-supplied digest must equal the canonical digest of the
|
|
164
|
+
# receipt payload; arbitrary or all-zero digests are rejected.
|
|
165
|
+
def verify_digest(receipt)
|
|
166
|
+
return if receipt.digest == Atoms::EvidenceDigest.digest(receipt.digest_payload)
|
|
167
|
+
|
|
168
|
+
reject("receipt digest mismatch: supplied digest does not match the canonical payload")
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Review receipts need an executed, independent reviewer verdict for
|
|
172
|
+
# exactly the tested head — a report existing is not approval, and a
|
|
173
|
+
# review receipt without a reviewer verdict is not evidence.
|
|
174
|
+
def verify_review(data)
|
|
175
|
+
review = data["review"]
|
|
176
|
+
if review.nil?
|
|
177
|
+
if data["operation"] == REVIEW_OPERATION && data["verdict"] == "succeeded"
|
|
178
|
+
reject("review receipt requires an executed independent reviewer verdict")
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
return
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
reject_unless(review.is_a?(Hash), "review must be an object")
|
|
185
|
+
reviewer = review["reviewer"]
|
|
186
|
+
reject_unless(reviewer.is_a?(Hash) && !reviewer["actor"].to_s.strip.empty?, "review must name an executed reviewer")
|
|
187
|
+
|
|
188
|
+
producer_actor = data.dig("producer", "actor").to_s.strip
|
|
189
|
+
if reviewer["actor"].to_s.strip == producer_actor
|
|
190
|
+
reject("reviewer and producer must differ (self-approval rejected)")
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
reject("review verdict must be '#{APPROVED_VERDICT}' for a succeeded receipt") if review["verdict"] != APPROVED_VERDICT && data["verdict"] == "succeeded"
|
|
194
|
+
unless review["head"] == data["head"]
|
|
195
|
+
reject("review head #{review['head']} does not match receipt head #{data['head']}")
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Resolve a project-relative artifact path to its real location,
|
|
200
|
+
# rejecting traversal and symlink escapes (an in-repo symlink must
|
|
201
|
+
# not smuggle in an external file); returns nil when the path leaves
|
|
202
|
+
# the root.
|
|
203
|
+
def safe_resolve(repo_root, path)
|
|
204
|
+
root = File.realpath(repo_root)
|
|
205
|
+
candidate = File.expand_path(path, root)
|
|
206
|
+
return nil unless candidate == root || candidate.start_with?(root + File::SEPARATOR)
|
|
207
|
+
return nil unless File.exist?(candidate)
|
|
208
|
+
|
|
209
|
+
resolved = File.realpath(candidate)
|
|
210
|
+
return nil unless resolved == root || resolved.start_with?(root + File::SEPARATOR)
|
|
211
|
+
|
|
212
|
+
resolved
|
|
213
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
214
|
+
nil
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def reject(message)
|
|
218
|
+
raise AttemptErrors::ReceiptRejected, message
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def reject_unless(condition, message)
|
|
222
|
+
reject(message) unless condition
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -56,8 +56,12 @@ module Ace
|
|
|
56
56
|
#
|
|
57
57
|
# @param config_path [String] Path to job.yaml config
|
|
58
58
|
# @param parent_id [String, nil] Parent assignment ID for hierarchy linking
|
|
59
|
+
# @param task_id [String, nil] Source task ID attaching this assignment
|
|
60
|
+
# (also read from the job's assignment.task_id); nil = taskless
|
|
61
|
+
# @param project_id [String, nil] Project ID (also read from the job's
|
|
62
|
+
# assignment.project_id)
|
|
59
63
|
# @return [Hash] Result with assignment and first step
|
|
60
|
-
def start(config_path, parent_id: nil)
|
|
64
|
+
def start(config_path, parent_id: nil, task_id: nil, project_id: nil)
|
|
61
65
|
raise ConfigErrors::NotFound, "Config file not found: #{config_path}" unless File.exist?(config_path)
|
|
62
66
|
|
|
63
67
|
config = YAML.safe_load_file(config_path, permitted_classes: [Time, Date])
|
|
@@ -74,12 +78,14 @@ module Ace
|
|
|
74
78
|
steps_config = expand_sub_steps(steps_config)
|
|
75
79
|
steps_config = materialize_skill_backed_steps(steps_config)
|
|
76
80
|
|
|
77
|
-
# Create assignment
|
|
81
|
+
# Create assignment; explicit args win over job config keys
|
|
78
82
|
assignment = assignment_manager.create(
|
|
79
83
|
name: assignment_config["name"] || File.basename(config_path, ".yaml"),
|
|
80
84
|
description: assignment_config["description"],
|
|
81
85
|
source_config: config_path,
|
|
82
|
-
parent: parent_id
|
|
86
|
+
parent: parent_id,
|
|
87
|
+
task_id: task_id || assignment_config["task_id"],
|
|
88
|
+
project_id: project_id || assignment_config["project_id"]
|
|
83
89
|
)
|
|
84
90
|
|
|
85
91
|
# Create initial step files
|