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 +4 -4
- data/CHANGELOG.md +10 -0
- data/lib/ace/lab/atoms/service_input.rb +11 -2
- data/lib/ace/lab/models/service_receipt.rb +1 -1
- data/lib/ace/lab/molecules/hitl_authorizer.rb +63 -0
- data/lib/ace/lab/molecules/service_executor.rb +64 -16
- data/lib/ace/lab/molecules/service_policy.rb +83 -12
- data/lib/ace/lab/organisms/service_request_service.rb +132 -10
- data/lib/ace/lab/version.rb +1 -1
- data/lib/ace/lab.rb +1 -0
- metadata +5 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1cd47480daa53d48cead3bb733e54223a0614394f27614248829ee4566a71da3
|
|
4
|
+
data.tar.gz: 6960967135776f222d2b2c638a0317e3a2b1afceb811155046f87fb41d476d65
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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" =>
|
|
34
|
-
rescue JSON::ParserError,
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
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"] == "
|
|
120
|
+
return result(claimed) unless claimed["state"] == "uncertain" && claimed["journal_commit"]
|
|
58
121
|
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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)
|
data/lib/ace/lab/version.rb
CHANGED
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.
|
|
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-
|
|
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.
|
|
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.
|
|
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
|