ace-lab 0.2.0 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c6487b764fe853b0272a43afa43fd2e6819669c6dc3593022ab55946925b2a2e
4
- data.tar.gz: c588bd4bc130eff246e02546b40a11fa9ed06e8c575560d292a291c5d2d1665e
3
+ metadata.gz: 1cd47480daa53d48cead3bb733e54223a0614394f27614248829ee4566a71da3
4
+ data.tar.gz: 6960967135776f222d2b2c638a0317e3a2b1afceb811155046f87fb41d476d65
5
5
  SHA512:
6
- metadata.gz: 3c6974042b3ac8cc638d3782b3d2e4c4be19de2de5860cc6c91e3d020cdc0338e7cb187d932ae64353f7994304b3b0f284533cef21b6919233e313a7af1bea6a
7
- data.tar.gz: 2e6908502fdd3870f7403e7d6f56b07d1b5edb07acbdc6591135aae8a0227ae85ab8724c7e079e4a1d91bbf61a53277d0a84e38c23be958e540c1b9c7d8306ae
6
+ metadata.gz: 10939c1ffa2ca6045f8f6e526f9fc96be4dba7c4d453e483a785e4570c57356ad6f3bfaf62cf8b4a20c0373cce8f5a261d18ec77ec2607393bcc3cc1facd2e21
7
+ data.tar.gz: 5f0d847b422a83437fd6431de880b7aa4f6e72c44dff00df9e0d359a85ba30600e0f70dbcb31d910ae34aa1f2a8a0419867c9b018f78b53273e588cc6645098e
data/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.1] - 2026-10-04
11
+
12
+ ### Changed
13
+ - Track the runtime-neutral ace-assign 0.62 line (the published 0.3.0 still allows `~> 0.61`, which resolves 0.62 — this release ships the tightened constraint).
14
+
15
+ ## [0.3.0] - 2026-10-04
16
+
17
+ ### Added
18
+ - **HITL delivery authorization policy (spec 8wq.t.34i)**: `Molecules::HitlAuthorizer` derives the lab-side transport facts (trusted transport uids, trusted HITL service identity, per-project Captain visibility) from the SAME trusted deployment document the grant resolver reads — uid-exact, project-scoped, never from claimed names or the cascade.
19
+
10
20
  ## [0.2.0] - 2026-10-02
11
21
 
12
22
  ### Added
@@ -16,8 +16,17 @@ module Ace
16
16
 
17
17
  def self.load(path)
18
18
  raise ArgumentError, "input file is missing" unless File.file?(path)
19
- raise ArgumentError, "input exceeds #{MAX_BYTES} bytes" if File.size(path) > MAX_BYTES
20
- data = JSON.parse(File.read(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
+ 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)
26
+ end
27
+ rescue Errno::EACCES, Errno::EISDIR
28
+ raise ArgumentError, "input file is unreadable"
29
+ end
21
30
  raise ArgumentError, "input must be a JSON object" unless data.is_a?(Hash)
22
31
  validate!(data)
23
32
  data
@@ -7,7 +7,7 @@ module Ace
7
7
  # The coordinator independently verifies every binding before acceptance.
8
8
  module ServiceReceipt
9
9
  BINDING_FIELDS = %w[request_id assignment_id attempt_id project_id operation
10
- input_digest target candidate_head].freeze
10
+ input_digest target candidate_head executor_uid transport].freeze
11
11
 
12
12
  def self.build(binding, executor_result)
13
13
  BINDING_FIELDS.to_h { |field| [field, binding.fetch(field)] }.merge(executor_result)
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+
5
+ module Ace
6
+ module Lab
7
+ module Molecules
8
+ # The lab-side HITL delivery authorization (spec 8wq.t.34i): which
9
+ # authenticated transport may submit correlated answers for a
10
+ # Captain, derived from the SAME trusted deployment document that
11
+ # carries the service principals — read through the grant
12
+ # resolver's verified traversal, never from the cascade. The peer
13
+ # uid is a KERNEL fact resolved by the HITL boundary; this policy
14
+ # never trusts a claimed name.
15
+ class HitlAuthorizer
16
+ # @param principals [Hash] authorization.principals from the
17
+ # trusted document (uid-string or username keys, per-project)
18
+ # @param transport_uids [Array<Integer>] uids authorized to act
19
+ # as the configured transport
20
+ # @param service_uid [Integer, nil] the trusted HITL service uid
21
+ def initialize(principals: {}, transport_uids: [], service_uid: nil)
22
+ @principals = principals || {}
23
+ @transport_uids = Array(transport_uids)
24
+ @service_uid = service_uid
25
+ end
26
+
27
+ # Build from the trusted deployment document at a fixed path
28
+ # (same file the service policy resolves principals from).
29
+ # @return [HitlAuthorizer]
30
+ def self.from_trusted_document(path, loader = GrantResolver)
31
+ document = loader.trusted_document(path)
32
+ hitl = document["hitl"].is_a?(Hash) ? document["hitl"] : {}
33
+ principals = document.dig("authorization", "principals")
34
+ new(
35
+ principals: principals.is_a?(Hash) ? principals : {},
36
+ transport_uids: Array(hitl["transport_uids"]),
37
+ service_uid: hitl["service_uid"]
38
+ )
39
+ end
40
+
41
+ # @return [Integer, nil] the trusted HITL service identity
42
+ def service_uid
43
+ @service_uid.is_a?(Integer) ? @service_uid : nil
44
+ end
45
+
46
+ # The transport may submit a correlated answer from an authorized
47
+ # Captain when its KERNEL-VERIFIED uid is a configured transport
48
+ # AND the transport's principal is visible for the request's
49
+ # project. Unknown identity is an error-shaped refusal, never
50
+ # permission.
51
+ def delivery_authorized?(uid:, project_id:)
52
+ return false unless @transport_uids.include?(uid)
53
+
54
+ policy = @principals[uid.to_s]
55
+ return false unless policy.is_a?(Hash)
56
+
57
+ projects = Array(policy["projects"]).map(&:to_s)
58
+ projects.empty? || projects.include?(project_id.to_s)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -13,12 +13,21 @@ module Ace
13
13
  # Domain handlers receive structured JSON on stdin and return a small
14
14
  # receipt; their stdout/stderr are never journaled or returned to callers.
15
15
  class ServiceExecutor
16
- def execute(operation:, request:, input:, policy:, authorization:)
17
- current = policy.operation!(request.fetch("operation"), project: request.fetch("project_id"),
16
+ # The trusted policy is reloaded through +policy_loader+ immediately
17
+ # before the effect: a revocation or operation change after the claim
18
+ # must still stop dispatch. The candidate head is revalidated through
19
+ # +head_loader+ for the same reason: a concurrent commit must not let
20
+ # a stale reviewed candidate execute.
21
+ def execute(operation:, request:, input:, policy_loader:, authorization:, head_loader:)
22
+ fresh = policy_loader.call
23
+ current = fresh.operation!(request.fetch("operation"), project: request.fetch("project_id"),
18
24
  service_id: request.fetch("service_id"))
19
25
  raise SecurityError, "executor operation changed before dispatch" unless current == operation
20
- policy.authorize!(authorization, request)
21
- if Time.iso8601(operation.fetch("lease_expires_at")) <= Time.now.utc
26
+ fresh.authorize!(authorization, request)
27
+ unless head_loader.call == request.fetch("candidate_head")
28
+ raise SecurityError, "candidate head changed before dispatch"
29
+ end
30
+ if Time.iso8601(current.fetch("lease_expires_at")) <= Time.now.utc
22
31
  raise SecurityError, "executor lease has expired"
23
32
  end
24
33
  out = if operation.fetch("transport", "local") == "unix"
@@ -30,36 +39,75 @@ module Ace
30
39
  response = JSON.parse(out)
31
40
  return nil unless valid_response?(response, request)
32
41
  {"outcome" => response.fetch("outcome"), "evidence" => response.fetch("evidence"),
33
- "executor_uid" => operation.fetch("executor_uid")}
34
- rescue JSON::ParserError, Errno::ENOENT, Errno::EACCES, Errno::ECONNREFUSED, EOFError, Timeout::Error
42
+ "executor_uid" => current.fetch("executor_uid")}
43
+ rescue JSON::ParserError, Timeout::Error, SystemCallError, IOError
44
+ # Transport failures after the claim (broken pipe, connection
45
+ # reset, socket loss) leave the effect unknown: uncertainty, not a
46
+ # classified crash, is the honest outcome.
35
47
  nil
36
48
  end
37
49
 
38
50
  private
39
51
 
52
+ # Handler output is bounded DURING execution: a noisy handler is
53
+ # killed and reaped once its response exceeds the receipt limit
54
+ # instead of being buffered without bound in this process.
40
55
  def invoke_local(operation, request, input)
41
56
  unless Process.uid == operation.fetch("executor_uid") && Process.euid == operation.fetch("executor_uid")
42
57
  raise SecurityError, "current OS identity is not the configured executor"
43
58
  end
44
- out, _stderr, status = Timeout.timeout(30) do
45
- Open3.capture3(*operation.fetch("argv"),
46
- stdin_data: JSON.generate({"request" => request, "input" => input}))
59
+ out = nil
60
+ status = nil
61
+ Open3.popen3(*operation.fetch("argv")) do |stdin, stdout, stderr, waiter|
62
+ pid = waiter.pid
63
+ begin
64
+ Timeout.timeout(30) do
65
+ stdin.write(JSON.generate({"request" => request, "input" => input}))
66
+ stdin.close
67
+ errors = Thread.new { bounded_read(stderr, 8192) }
68
+ out = bounded_read(stdout, 16 * 1024 + 1)
69
+ errors.join
70
+ Process.kill("KILL", pid) if out && out.bytesize > 16 * 1024
71
+ status = waiter.value
72
+ end
73
+ rescue Timeout::Error
74
+ Process.kill("KILL", pid) rescue nil
75
+ waiter.value
76
+ raise
77
+ end
47
78
  end
48
- status.success? ? out : nil
79
+ out if out && out.bytesize <= 16 * 1024 && status&.success?
49
80
  end
50
81
 
82
+ def bounded_read(io, limit)
83
+ io.read(limit).to_s
84
+ rescue IOError, Errno::EBADF
85
+ ""
86
+ ensure
87
+ begin
88
+ io.close
89
+ rescue IOError, Errno::EBADF
90
+ nil
91
+ end
92
+ end
93
+
94
+
51
95
  def invoke_unix(operation, request, input)
52
96
  path = operation.fetch("socket_path")
53
97
  stat = File.lstat(path)
54
98
  unless stat.socket? && stat.uid == operation.fetch("executor_uid") && (stat.mode & 0o002).zero?
55
99
  raise SecurityError, "service socket owner or permissions do not match the executor"
56
100
  end
57
- UNIXSocket.open(path) do |socket|
58
- peer_uid, = socket.getpeereid
59
- raise SecurityError, "service peer identity does not match the executor" unless peer_uid == stat.uid
60
- socket.write(JSON.generate({"request" => request, "input" => input}) + "\n")
61
- socket.flush
62
- Timeout.timeout(30) { socket.gets(16 * 1024 + 1) }
101
+ # One deadline covers connecting, writing, and reading: a stalled
102
+ # peer must leave the claimed effect uncertain, never block.
103
+ Timeout.timeout(30) do
104
+ UNIXSocket.open(path) do |socket|
105
+ peer_uid, = socket.getpeereid
106
+ raise SecurityError, "service peer identity does not match the executor" unless peer_uid == stat.uid
107
+ socket.write(JSON.generate({"request" => request, "input" => input}) + "\n")
108
+ socket.flush
109
+ socket.gets(16 * 1024 + 1)
110
+ end
63
111
  end
64
112
  end
65
113
 
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "pathname"
3
4
  require "time"
4
5
 
5
6
  module Ace
@@ -41,19 +42,38 @@ module Ace
41
42
  end
42
43
  uid = operation["executor_uid"]
43
44
  raise Ace::Lab::InvalidConfigurationError, "configured executor UID is invalid" unless uid.is_a?(Integer) && uid >= 0
44
- validate_executable!(operation["argv"].first, uid) if transport == "local"
45
+ if transport == "local"
46
+ # Every path component is validated (symlinks resolved, writable
47
+ # ancestors rejected) and the validated resolved file replaces
48
+ # the configured argv entry, so execution cannot be redirected
49
+ # between validation and dispatch.
50
+ resolved = trusted_executable!(operation["argv"].first, uid)
51
+ operation = operation.merge("argv" => [resolved] + operation["argv"][1..])
52
+ end
45
53
  expires_at = parse_time(operation["lease_expires_at"])
46
54
  raise SecurityError, "executor lease has expired" if expires_at <= Time.now.utc
47
55
  if operation["host_maintenance"] == true
48
- deployment = operation["deployment_root"].to_s
49
- sink = operation["evidence_sink"].to_s
56
+ # Maintenance executes a fixed updater outside the replaced
57
+ # deployment; a socket-only operation has no executable to place
58
+ # and cannot prove the maintenance boundary.
59
+ unless transport == "local"
60
+ raise Ace::Lab::InvalidConfigurationError,
61
+ "host maintenance requires a local executable transport"
62
+ end
63
+ deployment_root = operation["deployment_root"].to_s
64
+ sink_path = operation["evidence_sink"].to_s
65
+ unless deployment_root.start_with?("/") && sink_path.start_with?("/")
66
+ raise Ace::Lab::InvalidConfigurationError,
67
+ "host maintenance needs absolute deployment and evidence sink paths"
68
+ end
69
+ deployment = resolved_path(deployment_root)
70
+ sink = resolved_path(sink_path)
50
71
  executable = operation.dig("argv", 0).to_s
51
72
  unless deployment.start_with?("/") && sink.start_with?("/") && executable.start_with?("/") &&
52
73
  !inside?(executable, deployment) && !inside?(sink, deployment)
53
74
  raise Ace::Lab::InvalidConfigurationError,
54
75
  "host maintenance needs executable and evidence sink outside the replaced deployment"
55
76
  end
56
- validate_executable!(executable, uid)
57
77
  end
58
78
  operation
59
79
  end
@@ -72,15 +92,24 @@ module Ace
72
92
 
73
93
  private
74
94
 
75
- def validate_executable!(path, uid)
76
- file = File.stat(path)
77
- parent = File.stat(File.dirname(File.realpath(path)))
78
- unless [0, uid].include?(file.uid) && [0, uid].include?(parent.uid) &&
79
- (file.mode & 0o022).zero? && (parent.mode & 0o022).zero?
80
- raise Ace::Lab::InvalidConfigurationError,
81
- "configured executable or containing directory is writable by another identity"
95
+ # Validate the executable and every directory component on its
96
+ # resolved path: each must be owned by root or the executor and not
97
+ # group/other-writable, so no ancestor or symlink component can
98
+ # redirect the fixed argv. Returns the resolved file path.
99
+ def trusted_executable!(path, uid)
100
+ resolved = resolved_path(path.to_s)
101
+ unless resolved.start_with?("/") && File.file?(resolved) && File.executable?(resolved)
102
+ raise Ace::Lab::InvalidConfigurationError, "configured executable is unavailable"
82
103
  end
83
- rescue Errno::ENOENT, Errno::EACCES
104
+ Pathname.new(resolved).descend do |component|
105
+ stat = File.stat(component)
106
+ unless [0, uid].include?(stat.uid) && (stat.mode & 0o022).zero?
107
+ raise Ace::Lab::InvalidConfigurationError,
108
+ "configured executable path has a writable or foreign-owned component: #{component}"
109
+ end
110
+ end
111
+ resolved
112
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
84
113
  raise Ace::Lab::InvalidConfigurationError, "configured executable is unavailable"
85
114
  end
86
115
 
@@ -90,6 +119,48 @@ module Ace
90
119
  expanded == base || expanded.start_with?(base + File::SEPARATOR)
91
120
  end
92
121
 
122
+ # Resolve a configured path through symlinks so placement checks see
123
+ # the real location. Paths may not exist yet (an evidence sink is
124
+ # created on first use), so the deepest ancestor — counting symlink
125
+ # components themselves via lstat — is resolved instead of failing;
126
+ # a dangling symlink resolves through its own target chain rather
127
+ # than hiding behind its parent.
128
+ MAX_SYMLINK_DEPTH = 16
129
+
130
+ def resolved_path(path, depth = 0)
131
+ raise Ace::Lab::InvalidConfigurationError, "trusted policy path has a symlink cycle" if depth > MAX_SYMLINK_DEPTH
132
+ expanded = File.expand_path(path)
133
+ candidate = expanded
134
+ candidate = File.dirname(candidate) until candidate == "/" || lstat?(candidate)
135
+ resolved = begin
136
+ File.realpath(candidate)
137
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
138
+ dangling_target(candidate, depth) || candidate
139
+ end
140
+ return resolved unless resolved == candidate && lstat?(candidate) && File.symlink?(candidate)
141
+ dangling_target(candidate, depth) || resolved
142
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
143
+ expanded
144
+ end
145
+
146
+ # Follow a dangling symlink's own target chain; nil when the link
147
+ # cannot be resolved to any concrete location.
148
+ def dangling_target(link, depth = 0)
149
+ raise Ace::Lab::InvalidConfigurationError, "trusted policy path has a symlink cycle" if depth > MAX_SYMLINK_DEPTH
150
+ target = File.readlink(link)
151
+ target = File.expand_path(target, File.dirname(link)) unless Pathname.new(target).absolute?
152
+ resolved_path(target, depth + 1)
153
+ rescue Errno::ENOENT, Errno::EACCES
154
+ nil
155
+ end
156
+
157
+ def lstat?(path)
158
+ File.lstat(path)
159
+ true
160
+ rescue Errno::ENOENT
161
+ false
162
+ end
163
+
93
164
  def parse_time(value)
94
165
  Time.iso8601(value.to_s)
95
166
  rescue ArgumentError
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+ require "fileutils"
3
5
  require "open3"
4
6
  require "ace/assign"
5
7
 
@@ -9,12 +11,19 @@ module Ace
9
11
  # Coordinates the generic request contract. ace-assign owns the durable
10
12
  # claim and receipt; this class never writes evidence files directly.
11
13
  class ServiceRequestService
12
- def initialize(topology: nil, policy: nil, coordinator: nil, executor: nil, repo_root: Dir.pwd)
14
+ # Immutable request identity: a replay must match every field; the
15
+ # candidate head is deliberately absent because replays return the
16
+ # head recorded at claim time.
17
+ SERVICE_REPLAY_FIELDS = %w[project_id operation input_digest assignment_id attempt_id
18
+ target authorization caller_uid request_id].freeze
19
+
20
+ def initialize(topology: nil, policy: nil, coordinator: nil, executor: nil, repo_root: nil)
13
21
  @topology = topology || TopologyService.from_config
14
22
  @policy = policy
15
- @coordinator = coordinator || Ace::Assign::Organisms::AttemptCoordinator.new(repo_root: repo_root)
23
+ @repo_root = repo_root || git_toplevel
24
+ @coordinator = coordinator ||
25
+ Ace::Assign::Organisms::AttemptCoordinator.new(repo_root: @repo_root)
16
26
  @executor = executor || Molecules::ServiceExecutor.new
17
- @repo_root = repo_root
18
27
  end
19
28
 
20
29
  def request(project:, assignment:, attempt:, operation:, input_path:, authorization:, request_id:, dry_run: false)
@@ -24,6 +33,38 @@ module Ace
24
33
  end
25
34
  input = Atoms::ServiceInput.load(input_path)
26
35
  target = Atoms::ServiceInput.target(input)
36
+ # An identical retry returns the stored outcome — with the head
37
+ # recorded at claim time — without re-checking attempt, policy, or
38
+ # routing: a completed request must survive attempt terminality,
39
+ # authorization expiry, routing changes, and candidate-head
40
+ # advance. Any changed immutable field under the same request ID is
41
+ # a conflict, never a replay.
42
+ existing = @coordinator.service_request_status(request_id)
43
+ if existing
44
+ # Current project visibility gates replays: a revoked grant must
45
+ # not expose stored receipts, though the old operation route and
46
+ # authorization decision do not need to remain active.
47
+ visibility = @topology.services(project: project)
48
+ return visibility.envelope unless visibility.ok?
49
+ supplied = {"request_id" => request_id, "assignment_id" => assignment, "attempt_id" => attempt,
50
+ "project_id" => project, "operation" => operation,
51
+ "input_digest" => Atoms::ServiceInput.digest(input), "target" => target,
52
+ "caller_uid" => Process.uid, "authorization" => authorization}
53
+ changed = SERVICE_REPLAY_FIELDS.find do |field|
54
+ existing[field] != supplied.fetch(field)
55
+ end
56
+ if changed
57
+ raise Ace::Assign::AttemptErrors::Conflict,
58
+ "Service request #{request_id} has different #{changed}"
59
+ end
60
+ if dry_run
61
+ # A preview of an existing request still validates current
62
+ # eligibility and reports itself as a dry run.
63
+ @coordinator.request_eligible!(existing)
64
+ return {"status" => "ok", "data" => result(existing).fetch("data").merge("dry_run" => true)}
65
+ end
66
+ return result(existing)
67
+ end
27
68
  route = @topology.route(project: project, capability: operation)
28
69
  return route.envelope unless route.ok?
29
70
  service_id = route.data.fetch("entry").fetch("id")
@@ -45,22 +86,52 @@ module Ace
45
86
  @coordinator.reject_service_request(binding, reason: "policy_rejected") unless dry_run
46
87
  raise
47
88
  end
89
+ # The trusted policy owns executor identity and transport; the
90
+ # journaled claim binds them so only that executor identity can
91
+ # later complete the receipt. A local operation whose configured
92
+ # executor is not this process is refused before any claim, so no
93
+ # authorization is consumed by a dispatch that cannot happen.
94
+ binding["executor_uid"] = operation_policy.fetch("executor_uid")
95
+ binding["transport"] = transport = operation_policy.fetch("transport", "local")
96
+ if transport == "local" &&
97
+ (Process.uid != binding["executor_uid"] || Process.euid != binding["executor_uid"])
98
+ # A preview records nothing: the durable rejection is only for
99
+ # real submissions, so the request ID stays reusable.
100
+ @coordinator.reject_service_request(binding, reason: "executor_identity_unavailable") unless dry_run
101
+ raise SecurityError, "current OS identity is not the configured executor"
102
+ end
48
103
 
49
104
  if dry_run
105
+ # The preview applies the same read-only eligibility checks as
106
+ # the claim — review evidence for external effects and the
107
+ # current candidate head — so it cannot promise acceptance for a
108
+ # request that would fail on submission.
109
+ @coordinator.request_eligible!(binding)
50
110
  preview = {"outcome" => "accepted", "dry_run" => true, "request_id" => request_id,
51
111
  "project" => project, "operation" => operation, "service_id" => service_id,
52
112
  "target" => target, "candidate_head" => head}
53
113
  return {"status" => "ok", "data" => preview}
54
114
  end
55
115
 
116
+ # The claim itself records uncertain (dispatch intent): a crash
117
+ # after the claim leaves uncertainty, never a stranded accepted
118
+ # state that a retry would return without dispatching.
56
119
  claimed = @coordinator.claim_service_request(binding)
57
- return result(claimed) unless claimed["state"] == "accepted" && claimed["journal_commit"]
120
+ return result(claimed) unless claimed["state"] == "uncertain" && claimed["journal_commit"]
58
121
 
59
- # From here, a crash or lost executor response must not authorize a
60
- # replay. Record uncertainty before invoking an external process.
61
- @coordinator.transition_service_request(request_id, state: "uncertain")
62
- receipt = @executor.execute(operation: operation_policy, request: binding, input: input,
63
- policy: policy, authorization: authorization)
122
+ # The executor reloads the trusted policy from disk at the effect
123
+ # boundary: a revocation after the claim must still stop dispatch.
124
+ begin
125
+ receipt = @executor.execute(operation: operation_policy, request: binding, input: input,
126
+ policy_loader: policy_loader, authorization: authorization,
127
+ head_loader: -> { current_head })
128
+ rescue SecurityError, Ace::Lab::InvalidConfigurationError => e
129
+ # Refusal before invocation: no effect was attempted. When this
130
+ # process is the executor identity (local transport), record a
131
+ # proven no-effect settlement instead of stranding uncertainty.
132
+ settle_pre_invocation_refusal(binding, request_id, e)
133
+ raise
134
+ end
64
135
  return result(@coordinator.service_request_status(request_id)) if receipt.nil?
65
136
 
66
137
  state = receipt.fetch("outcome")
@@ -97,6 +168,54 @@ module Ace
97
168
 
98
169
  private
99
170
 
171
+ # A refusal raised before the handler was invoked proves no effect
172
+ # was attempted. When this process runs as the executor identity
173
+ # (local transport), record the proven no-effect settlement with an
174
+ # executor-owned attestation; otherwise the claim stays uncertain
175
+ # for evidence-based reconciliation.
176
+ def settle_pre_invocation_refusal(binding, request_id, error)
177
+ return unless binding["transport"] == "local" && Process.uid == binding["executor_uid"]
178
+ evidence_dir = File.join(@repo_root, "evidence", "service")
179
+ FileUtils.mkdir_p(evidence_dir)
180
+ attestation = File.join(evidence_dir, "#{request_id}-no-effect")
181
+ content = "ace-service-attestation request:#{request_id} " \
182
+ "input:#{binding.fetch("input_digest")} outcome:failed no-effect:true\n" \
183
+ "refusal: #{error.message}\n"
184
+ # Exclusive creation without following symlinks: the caller-controlled
185
+ # request ID must never truncate an existing file, and the artifact
186
+ # must stay private to pass the executor-owned verification.
187
+ File.open(attestation, File::WRONLY | File::CREAT | File::EXCL, 0600) do |file|
188
+ file.write(content)
189
+ end
190
+ receipt = Models::ServiceReceipt.build(binding, {
191
+ "outcome" => "failed",
192
+ "evidence" => [{"ref" => "evidence/service/#{request_id}-no-effect",
193
+ "sha256" => Digest::SHA256.hexdigest(content)}],
194
+ "executor_uid" => binding.fetch("executor_uid")
195
+ })
196
+ @coordinator.reconcile_service_no_effect(request_id, receipt: receipt)
197
+ rescue Ace::Assign::AttemptErrors::InvalidState, Ace::Assign::AttemptErrors::ReceiptRejected,
198
+ Ace::Assign::AttemptErrors::NotFound, Errno::ENOENT, Errno::EACCES, Errno::EEXIST
199
+ nil
200
+ end
201
+
202
+ # The Git toplevel (not a package directory that happens to hold a
203
+ # Rakefile) bounds evidence verification for nested invocations.
204
+ def git_toplevel
205
+ out, status = Open3.capture2("git", "rev-parse", "--show-toplevel")
206
+ (status&.success? ? out.strip : Dir.pwd)
207
+ rescue Errno::ENOENT
208
+ Dir.pwd
209
+ end
210
+
211
+ def policy_loader
212
+ if @policy
213
+ -> { @policy }
214
+ else
215
+ -> { Molecules::ServicePolicy.new(Molecules::GrantResolver.trusted_document(Ace::Lab.authorization_path)) }
216
+ end
217
+ end
218
+
100
219
  def validate_attempt!(binding)
101
220
  attempt = @coordinator.service_attempt(binding)
102
221
  unless attempt.candidate_head.nil? || attempt.candidate_head == binding["candidate_head"]
@@ -113,7 +232,10 @@ module Ace
113
232
  def result(request)
114
233
  public = request.slice("request_id", "assignment_id", "attempt_id", "project_id", "operation",
115
234
  "input_digest", "target", "candidate_head", "service_id", "state", "receipt")
116
- {"status" => "ok", "data" => public.merge("outcome" => request.fetch("state"))}
235
+ # failed-settled is internal bookkeeping: the documented public
236
+ # outcome stays "failed"; the state field distinguishes settlement.
237
+ outcome = request.fetch("state") == "failed-settled" ? "failed" : request.fetch("state")
238
+ {"status" => "ok", "data" => public.merge("outcome" => outcome)}
117
239
  end
118
240
 
119
241
  def failure(code, message)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Lab
5
- VERSION = "0.2.0"
5
+ VERSION = "0.3.1"
6
6
  end
7
7
  end
data/lib/ace/lab.rb CHANGED
@@ -24,6 +24,7 @@ require_relative "lab/models/query_result"
24
24
  require_relative "lab/molecules/topology_loader"
25
25
  require_relative "lab/molecules/caller_authorizer"
26
26
  require_relative "lab/molecules/grant_resolver"
27
+ require_relative "lab/molecules/hitl_authorizer"
27
28
  require_relative "lab/molecules/service_policy"
28
29
  require_relative "lab/molecules/service_executor"
29
30
  require_relative "lab/molecules/inventory_query"
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-lab
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-02 00:00:00.000000000 Z
10
+ date: 2026-10-04 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-cli
@@ -57,14 +57,14 @@ dependencies:
57
57
  requirements:
58
58
  - - "~>"
59
59
  - !ruby/object:Gem::Version
60
- version: '0.61'
60
+ version: '0.62'
61
61
  type: :runtime
62
62
  prerelease: false
63
63
  version_requirements: !ruby/object:Gem::Requirement
64
64
  requirements:
65
65
  - - "~>"
66
66
  - !ruby/object:Gem::Version
67
- version: '0.61'
67
+ version: '0.62'
68
68
  - !ruby/object:Gem::Dependency
69
69
  name: ace-support-test-helpers
70
70
  requirement: !ruby/object:Gem::Requirement
@@ -187,6 +187,7 @@ files:
187
187
  - lib/ace/lab/molecules/capability_router.rb
188
188
  - lib/ace/lab/molecules/exact_resolver.rb
189
189
  - lib/ace/lab/molecules/grant_resolver.rb
190
+ - lib/ace/lab/molecules/hitl_authorizer.rb
190
191
  - lib/ace/lab/molecules/inventory_query.rb
191
192
  - lib/ace/lab/molecules/service_executor.rb
192
193
  - lib/ace/lab/molecules/service_policy.rb