ace-lab 0.1.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 54eee52265f20d22376fcdf7ac0a748be0c8ec5d7400259310a1a5a37d2f373e
4
- data.tar.gz: e9b3746081e8790a2764f8dcbbd54650ddb3a23890cdb653c689c5a5d6ad7d5c
3
+ metadata.gz: 521f7150bb3da5fcc12c475b5c2fce6c78808e78315e05731b43dc6632ffa599
4
+ data.tar.gz: 32a8f1106ff9321c5e6971411be50fad60497c770d7fcb38d19623e270700258
5
5
  SHA512:
6
- metadata.gz: 78d706d9b60416afdd98a12bbf98dfe90a12e2cd4105c29f3bab5b177dcdea70f8ac0753e35926107790afbdd1fc03c9e792b9c4fd85a45b1dac716122032060
7
- data.tar.gz: 646f3f2fae98bc011fabf589a1175c6248f36cd4cd89a7dc2172e0010287affbb16bebe4dc33ce8af5b53e263d2743d1af7c4410c2a307c0e08b215a57aad18f
6
+ metadata.gz: e8e32415403e0235b9e6fd4f9862aed6b1577d98d651a5984ae10f3073be918248766542dca0dfe58555ecb0843d8d7df85e869b8751b9b28d5a28e447ca6cdd
7
+ data.tar.gz: a12d71e70fc25b27fb2b85aeda273713dcbcebb477b29ada5a415b2f007ec3368fa6d8b5d2c8e9258bca8d4e63e7ff9965efa0d05be0f9534bdd739284eb38e1
data/CHANGELOG.md CHANGED
@@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.3.0] - 2026-10-04
11
+
12
+ ### Added
13
+ - **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.
14
+
15
+
16
+ ## [0.2.0] - 2026-10-02
17
+
18
+ ### Added
19
+
20
+ - `service request` and `service status` for exact authorized operations,
21
+ idempotent assignment-journal claims, peer-verified execution receipts,
22
+ dry-run previews, and conservative uncertain-state recovery.
23
+
24
+ ### Fixed
25
+
26
+ - Recheck the configured operation, authorization, and lease at dispatch;
27
+ bind terminal receipts to the assignment request before acceptance.
28
+
8
29
  ## [0.1.0] - 2026-09-28
9
30
 
10
31
  ### Added
data/README.md CHANGED
@@ -1,12 +1,14 @@
1
1
  ## ace-lab
2
2
 
3
- Topology and routing CLI for the Lab: address projects, agents, and services
4
- by stable IDs. Part of ACE (Agentic Coding Environment).
3
+ Topology, routing, and scoped service request CLI for the Lab. Part of ACE
4
+ (Agentic Coding Environment).
5
5
 
6
6
  `ace-lab` answers one question -- *what is the stable ID of the project, agent,
7
7
  or service I mean, and what is it authorized and able to do?* -- from
8
- configuration alone. It never invokes Lab, never reads credentials, tracks no
9
- work, and never guesses transient pane or session identifiers.
8
+ configuration. Topology queries never invoke a service. The `service`
9
+ commands submit configured operations through a verified executor and use
10
+ `ace-assign` for durable request evidence. No command hands credentials to
11
+ the caller or guesses transient pane or session identifiers.
10
12
 
11
13
  ### Install
12
14
 
@@ -17,12 +19,12 @@ work, and never guesses transient pane or session identifiers.
17
19
  Configure topology via the ADR-022 cascade (`~/.ace/lab/config.yml` or
18
20
  `.ace/lab/config.yml`; deployed values are owned by the `lab-config`
19
21
  repository). Authorization grants live in a separate deployment-controlled
20
- file at the fixed path `/etc/lab/ace-lab/authorization.yml` — root-owned and
21
- not group/world-writable, verified at every query — never in the
22
+ file at the fixed path `/etc/lab/ace-lab/authorization.yml` -- root-owned and
23
+ not group/world-writable, verified at every query -- never in the
22
24
  caller-writable cascade and never at a caller-selected location:
23
25
 
24
26
  ```yaml
25
- # .ace/lab/config.yml — topology only
27
+ # .ace/lab/config.yml -- topology only
26
28
  schema_version: 1
27
29
  topology:
28
30
  projects:
@@ -56,6 +58,17 @@ Query it:
56
58
  ace-lab resolve --id atlas-planner --format json
57
59
  ace-lab route --project atlas --capability search --format json
58
60
 
61
+ Request a configured operation for an active, managed assignment attempt:
62
+
63
+ ace-lab service request --project atlas --assignment A --attempt ATT \
64
+ --operation publish --input release.json --authorization DECISION \
65
+ --request-id REQUEST --dry-run
66
+ ace-lab service status --request REQUEST --format json
67
+
68
+ The trusted grants file may also contain exact `operations` and
69
+ `authorizations` mappings. See [usage](docs/usage.md) for the request schema,
70
+ executor transport, and uncertain-state recovery.
71
+
59
72
  Every command prints one deterministic JSON document:
60
73
 
61
74
  {"status":"ok","data":{"entry":{"id":"atlas-search","project":"atlas",
@@ -92,19 +105,18 @@ Failures are classified: `missing`, `ambiguous`, `stale`, `unauthorized`,
92
105
  are `missing`; several equal candidates need a configured `default_for`, or
93
106
  the result is `ambiguous`. Another project's service is never picked.
94
107
 
95
- - **Read-only by contract.** No Lab invocation, no execution state, no
108
+ - **Execution evidence is separate from topology.** `ace-lab service` uses a
96
109
 
97
- scheduling, no credentials -- service invocation and execution state belong
98
- to separate tools.
110
+ managed `ace-assign` attempt and its evidence ref. A lost executor receipt
111
+ remains uncertain and a repeated request ID never replays the effect.
99
112
 
100
113
  ### Non-goals
101
114
 
102
- `ace-lab` is not an execution-state engine. It does not invoke services
103
- (`8wr.t.qjx` owns the service contract), track Works or assignment state, or
104
- schedule anything. Deployed topology values are owned by `lab-config`
115
+ `ace-lab` does not track Works or schedule anything. `ace-assign` owns attempt
116
+ state; deployed operation handlers and topology values are owned by `lab-config`
105
117
  (`8wl.t.gad`); this package defines and validates the schema.
106
118
 
107
119
  ### Development
108
120
 
109
121
  bundle install
110
- ace-test ace-lab # or: bundle exec rake test from ace-lab/
122
+ bin/ace-test ace-lab all
data/docs/usage.md CHANGED
@@ -1,13 +1,14 @@
1
1
  # ace-lab usage
2
2
 
3
- Topology and routing CLI for the Lab. Resolves stable project, agent, and
4
- service IDs from configuration -- without knowing transient pane IDs or
5
- holding service credentials.
3
+ Topology, routing, and scoped service requests for the Lab. Resolves stable
4
+ project, agent, and service IDs without transient pane IDs or shared service
5
+ credentials.
6
6
 
7
7
  ## Commands
8
8
 
9
- All commands accept `--format json` (the only supported format; anything else
10
- is rejected) and `-q/--quiet` (suppress stdout; exit semantics unchanged).
9
+ Topology commands accept `--format json` (the only supported format) and
10
+ `-q/--quiet`. Service status accepts `--format json`; service request always
11
+ emits JSON.
11
12
 
12
13
  ### `ace-lab projects`
13
14
 
@@ -58,6 +59,68 @@ Select a configured capable service in the requested project.
58
59
 
59
60
  Routing never invokes the service and never grants credentials.
60
61
 
62
+ ### `ace-lab service request`
63
+
64
+ **Goal:** Ask a configured service to perform one named operation for an
65
+ active, task-attached assignment attempt. First create a JSON input file:
66
+
67
+ ```json
68
+ {"target":{"resource":"release/1.2.3","artifact_digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"arguments":{"version":"1.2.3"}}
69
+ ```
70
+
71
+ Run a preview, then submit the exact request:
72
+
73
+ ```sh
74
+ ace-lab service request --project atlas --assignment A --attempt ATT \
75
+ --operation publish --input release.json --authorization DECISION \
76
+ --request-id REQUEST --dry-run
77
+ ace-lab service request --project atlas --assignment A --attempt ATT \
78
+ --operation publish --input release.json --authorization DECISION \
79
+ --request-id REQUEST
80
+ ```
81
+
82
+ The preview reports `outcome: accepted` with `dry_run: true` and the selected
83
+ service ID, target, and candidate head. It creates no request claim or effect.
84
+ The second command returns `succeeded`, `failed`, or `uncertain` with a
85
+ non-secret receipt when the executor confirms an outcome. A missing response
86
+ leaves `uncertain` in the assignment evidence ref; do not retry with a new ID
87
+ until the external outcome is reconciled. Repeating the same ID and input
88
+ returns its stored state without dispatching again. A changed binding under
89
+ the same ID is a `conflict` error.
90
+
91
+ Input must be a JSON object of at most 64 KiB. `target.resource` is a stable
92
+ resource ID; `target.artifact_digest`, when supplied, is a SHA-256 hex digest.
93
+ Credential, password, secret, token, private-key, authorization, and env keys
94
+ are rejected recursively. Operation arguments remain structured JSON sent to
95
+ the executor; no caller-provided shell command or argv is accepted.
96
+
97
+ | Option | Purpose |
98
+ |--------|---------|
99
+ | `--project` | Project stable ID |
100
+ | `--assignment` | Assignment ID |
101
+ | `--attempt` | Active managed attempt ID |
102
+ | `--operation` | Configured named operation |
103
+ | `--input` | Structured JSON file |
104
+ | `--authorization` | Exact decision or configured automation reference |
105
+ | `--request-id` | Idempotency key; use the same ID for retries/status |
106
+ | `--dry-run` | Validate and show scope without an effect |
107
+
108
+ ### `ace-lab service status`
109
+
110
+ ```sh
111
+ ace-lab service status --request REQUEST --format json
112
+ ```
113
+
114
+ Expected output after a lost executor receipt:
115
+
116
+ ```json
117
+ {"status":"ok","data":{"request_id":"REQUEST","outcome":"uncertain","state":"uncertain"}}
118
+ ```
119
+
120
+ Only the original OS caller identity with project visibility can read the
121
+ request. The authoritative state survives loss of the local assignment cache
122
+ because it lives in the separate assignment evidence Git ref.
123
+
61
124
  ## Error semantics
62
125
 
63
126
  Errors are one deterministic JSON document on stdout plus a non-zero exit.
@@ -69,6 +132,10 @@ Errors are one deterministic JSON document on stdout plus a non-zero exit.
69
132
  | `stale` | Binding is inactive, unattested, or the instance identity was replaced |
70
133
  | `unauthorized` | Caller's verified local identity has no principal for the project |
71
134
  | `invalid_configuration` | Topology config violates the schema; message is actionable |
135
+ | `invalid_input` | Service input or request ID is malformed |
136
+ | `invalid_attempt` | Assignment attempt is absent, unmanaged, terminal, or has a stale candidate head |
137
+ | `conflict` | Request ID is already bound to different input or scope |
138
+ | `evidence_unavailable` | Assignment evidence ref cannot be written or read |
72
139
 
73
140
  Example:
74
141
 
@@ -115,7 +182,7 @@ Grants never live in the cascade: project and user documents are
115
182
  caller-writable, so an `authorization` section there is rejected as
116
183
  `invalid_configuration`. Grants come from a single deployment-controlled
117
184
  file at the **fixed path** `/etc/lab/ace-lab/authorization.yml` (installed
118
- by the `lab-config` deployment). The location is not caller-selectable —
185
+ by the `lab-config` deployment). The location is not caller-selectable --
119
186
  there is no flag or environment override. At every query the tool verifies
120
187
  the file and every directory on its real path are root-owned and not
121
188
  group/world-writable, and opens the file `O_NOFOLLOW`; any failed
@@ -130,12 +197,51 @@ principals:
130
197
  The grants file is machine-global: it is validated structurally only, so a
131
198
  principal may reference projects absent from the current directory's
132
199
  topology. Such grants are valid but never match a locally configured
133
- project. All-digit principal keys are matched as uids only — an all-digit
200
+ project. All-digit principal keys are matched as uids only -- an all-digit
134
201
  passwd username is authorized solely through its uid, so it can never
135
202
  consume a different account's numeric-uid grant.
136
203
 
137
204
  A missing trusted file means nobody is authorized (fail closed).
138
205
 
206
+ ### Trusted service policy
207
+
208
+ The same deployment-owned file may contain `operations` and
209
+ `authorizations`. An operation names one exact project and stable service ID,
210
+ an executor OS UID, a valid lease, and either a fixed local argv or an absolute
211
+ Unix socket path. The domain deployment owns actual handlers and credentials.
212
+ The Unix service must authenticate the client's OS peer identity, validate
213
+ the exact authorization and current lease itself, and return a structured
214
+ receipt; ACE verifies the service peer UID before accepting that response.
215
+
216
+ ```yaml
217
+ operations:
218
+ forge-sync:
219
+ project: atlas
220
+ service_id: atlas-sync
221
+ transport: unix
222
+ socket_path: /run/lab/atlas-sync.sock
223
+ executor_uid: 997
224
+ lease_expires_at: '2026-10-02T16:00:00Z'
225
+ authorizations:
226
+ DECISION:
227
+ operation: forge-sync
228
+ project_id: atlas
229
+ assignment_id: A
230
+ attempt_id: ATT
231
+ input_digest: <sha256-of-canonical-json>
232
+ target: { resource: forge/repository, artifact_digest: null }
233
+ candidate_head: <exact-git-head>
234
+ caller_uid: 1000
235
+ expires_at: '2026-10-02T16:00:00Z'
236
+ ```
237
+
238
+ The decision must match every listed field exactly and remain valid. A
239
+ decision never supplies missing executor capability or credentials. For
240
+ authenticated host maintenance, the policy also identifies an executable and
241
+ evidence sink outside the deployment being replaced; its domain handler must
242
+ enforce quiescence for all other product work. Environment `review-approval`
243
+ is a named operation and never substitutes for an independent code review.
244
+
139
245
  Validation of the topology cascade rejects duplicate IDs (globally unique
140
246
  across projects, agents, services), unknown project references, malformed
141
247
  capabilities, defaults for undeclared capabilities, unusable endpoints
@@ -146,7 +252,7 @@ unknown projects.
146
252
  **Error messages are value-free by design:** validation runs before
147
253
  authorization, so messages use positional field locations
148
254
  (`topology.agents[0].project references an unknown project`) and never echo
149
- configured IDs, project names, or principal names — configuration defects
255
+ configured IDs, project names, or principal names -- configuration defects
150
256
  cannot disclose topology to unauthorized callers.
151
257
 
152
258
  **Ownership:** deployed topology and authorization values are maintained by
@@ -172,7 +278,7 @@ reads; it never provisions.
172
278
  classify it `stale` -- an explicit unavailable result, never a routeable
173
279
  answer.
174
280
 
175
- - No Works, assignment state, or scheduling. Service invocation belongs to
281
+ - No Works or scheduling. Service request claims and receipts use
176
282
 
177
- `8wr.t.qjx`; execution state to `8wr.t.qjl`. The Lab execution binary
283
+ `ace-assign`'s evidence journal. The Lab execution binary
178
284
  (`/usr/local/bin/lab`) is never invoked or required.
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module Ace
7
+ module Lab
8
+ module Atoms
9
+ # Bounded structured input for a named service operation. Only the
10
+ # digest enters the request index; input values never enter the journal.
11
+ module ServiceInput
12
+ MAX_BYTES = 64 * 1024
13
+ FORBIDDEN_KEYS = /credential|password|secret|token|private.key|authorization|\Aenv\z/i
14
+ SHA256 = /\A[0-9a-f]{64}\z/
15
+ RESOURCE = /\A[a-zA-Z0-9_.:\/-]{1,256}\z/
16
+
17
+ def self.load(path)
18
+ raise ArgumentError, "input file is missing" unless File.file?(path)
19
+ # One handle bounds the read: a file replaced or grown between a
20
+ # size check and a separate read cannot bypass the limit.
21
+ 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
30
+ raise ArgumentError, "input must be a JSON object" unless data.is_a?(Hash)
31
+ validate!(data)
32
+ data
33
+ rescue JSON::ParserError
34
+ raise ArgumentError, "input must be valid JSON"
35
+ end
36
+
37
+ def self.digest(data)
38
+ Digest::SHA256.hexdigest(JSON.generate(canonical(data)))
39
+ end
40
+
41
+ def self.target(data)
42
+ target = data["target"]
43
+ raise ArgumentError, "input.target must be an object" unless target.is_a?(Hash)
44
+ resource = target["resource"]
45
+ artifact = target["artifact_digest"]
46
+ raise ArgumentError, "input.target.resource must be a stable resource ID" unless
47
+ resource.is_a?(String) && resource.match?(RESOURCE)
48
+ if artifact && (!artifact.is_a?(String) || !artifact.match?(SHA256))
49
+ raise ArgumentError, "input.target.artifact_digest must be SHA-256"
50
+ end
51
+ {"resource" => resource, "artifact_digest" => artifact}
52
+ end
53
+
54
+ def self.canonical(value)
55
+ case value
56
+ when Hash then value.keys.sort.each_with_object({}) { |key, result| result[key] = canonical(value[key]) }
57
+ when Array then value.map { |item| canonical(item) }
58
+ else value
59
+ end
60
+ end
61
+
62
+ def self.validate!(value, depth = 0)
63
+ raise ArgumentError, "input nesting is too deep" if depth > 16
64
+ case value
65
+ when Hash
66
+ value.each do |key, item|
67
+ raise ArgumentError, "input has a forbidden field" if !key.is_a?(String) || key.match?(FORBIDDEN_KEYS)
68
+ validate!(item, depth + 1)
69
+ end
70
+ when Array then value.each { |item| validate!(item, depth + 1) }
71
+ when String, Integer, Float, TrueClass, FalseClass, NilClass
72
+ raise ArgumentError, "input contains a non-finite number" if value.is_a?(Float) && !value.finite?
73
+ else
74
+ raise ArgumentError, "input contains an unsupported value"
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ace/support/cli"
5
+ require_relative "support"
6
+
7
+ module Ace
8
+ module Lab
9
+ module CLI
10
+ module Commands
11
+ module Service
12
+ module Output
13
+ def service
14
+ @service ||= Organisms::ServiceRequestService.new
15
+ end
16
+
17
+ def emit(envelope)
18
+ puts JSON.generate(envelope)
19
+ return if envelope["status"] == "ok"
20
+ error = envelope.fetch("error")
21
+ code = error.fetch("code")
22
+ message = error.fetch("message")
23
+ raise Ace::Support::Cli::Error, "#{code}: #{message}"
24
+ end
25
+ end
26
+
27
+ class Request < Ace::Support::Cli::Command
28
+ include Ace::Support::Cli::Base
29
+ include Runtime
30
+ include Output
31
+
32
+ desc "Request one authorized, configured service operation"
33
+ option :project, type: :string, required: true, desc: "Project stable ID"
34
+ option :assignment, type: :string, required: true, desc: "Assignment ID"
35
+ option :attempt, type: :string, required: true, desc: "Managed attempt ID"
36
+ option :operation, type: :string, required: true, desc: "Named operation"
37
+ option :input, type: :string, required: true, desc: "Structured JSON input file"
38
+ option :authorization, type: :string, required: true, desc: "Exact authorization reference"
39
+ option :request_id, type: :string, required: true, desc: "Idempotency key"
40
+ option :dry_run, type: :boolean, desc: "Validate without claiming or executing"
41
+
42
+ def call(project:, assignment:, attempt:, operation:, input:, authorization:, request_id:, **options)
43
+ reject_identity_flags!(options)
44
+ emit(service.request(project: project, assignment: assignment, attempt: attempt,
45
+ operation: operation, input_path: input, authorization: authorization,
46
+ request_id: request_id, dry_run: options[:dry_run]))
47
+ end
48
+ end
49
+
50
+ class Status < Ace::Support::Cli::Command
51
+ include Ace::Support::Cli::Base
52
+ include Runtime
53
+ include Output
54
+
55
+ desc "Read the authoritative outcome of a service request"
56
+ option :request, type: :string, required: true, desc: "Request ID"
57
+ option :format, type: :string, desc: "Output format (json only)"
58
+
59
+ def call(request:, **options)
60
+ ensure_json_format!(options)
61
+ emit(service.status(request_id: request))
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
data/lib/ace/lab/cli.rb CHANGED
@@ -8,6 +8,7 @@ require_relative "cli/commands/agents"
8
8
  require_relative "cli/commands/services"
9
9
  require_relative "cli/commands/resolve"
10
10
  require_relative "cli/commands/route"
11
+ require_relative "cli/commands/service"
11
12
 
12
13
  module Ace
13
14
  module Lab
@@ -23,7 +24,9 @@ module Ace
23
24
  ["agents", "List lab agents in a project by stable ID"],
24
25
  ["services", "List lab services in a project by stable ID"],
25
26
  ["resolve", "Resolve one lab entry by exact stable ID"],
26
- ["route", "Select a configured capable service in a project"]
27
+ ["route", "Select a configured capable service in a project"],
28
+ ["service request", "Request one authorized service operation"],
29
+ ["service status", "Read a service request outcome"]
27
30
  ].freeze
28
31
 
29
32
  HELP_EXAMPLES = [
@@ -47,6 +50,8 @@ module Ace
47
50
  register "services", CLI::Commands::Services.new
48
51
  register "resolve", CLI::Commands::Resolve.new
49
52
  register "route", CLI::Commands::Route.new
53
+ register "service request", CLI::Commands::Service::Request.new
54
+ register "service status", CLI::Commands::Service::Status.new
50
55
 
51
56
  # Register version command
52
57
  version_cmd = Ace::Support::Cli::VersionCommand.build(
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Lab
5
+ module Models
6
+ # Non-secret result submitted to the assignment evidence coordinator.
7
+ # The coordinator independently verifies every binding before acceptance.
8
+ module ServiceReceipt
9
+ BINDING_FIELDS = %w[request_id assignment_id attempt_id project_id operation
10
+ input_digest target candidate_head executor_uid transport].freeze
11
+
12
+ def self.build(binding, executor_result)
13
+ BINDING_FIELDS.to_h { |field| [field, binding.fetch(field)] }.merge(executor_result)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -23,6 +23,22 @@ module Ace
23
23
  MAX_SYMLINK_HOPS = 8
24
24
 
25
25
  class << self
26
+ # Read the full deployment-owned policy for service execution.
27
+ # Topology remains a routing hint; it never supplies authority.
28
+ def trusted_document(path)
29
+ content = read_verified(path)
30
+ return {} if content.nil?
31
+ document = YAML.safe_load(content, permitted_classes: [Date], aliases: true)
32
+ unless document.is_a?(Hash)
33
+ raise Ace::Lab::InvalidConfigurationError,
34
+ "invalid lab configuration: trusted authorization file must contain a mapping"
35
+ end
36
+ document
37
+ rescue Psych::Exception
38
+ raise Ace::Lab::InvalidConfigurationError,
39
+ "invalid lab configuration: trusted authorization file could not be parsed"
40
+ end
41
+
26
42
  # @param documents [Array<Hash>] {path:, document:, defaults:}
27
43
  # from Ace::Lab.cascade_documents
28
44
  # @param topology [Hash] normalized topology (project IDs)
@@ -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
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "socket"
6
+ require "timeout"
7
+ require "time"
8
+
9
+ module Ace
10
+ module Lab
11
+ module Molecules
12
+ # Executes one fixed, deployment-configured argv under its OS account.
13
+ # Domain handlers receive structured JSON on stdin and return a small
14
+ # receipt; their stdout/stderr are never journaled or returned to callers.
15
+ class ServiceExecutor
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"),
24
+ service_id: request.fetch("service_id"))
25
+ raise SecurityError, "executor operation changed before dispatch" unless current == operation
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
31
+ raise SecurityError, "executor lease has expired"
32
+ end
33
+ out = if operation.fetch("transport", "local") == "unix"
34
+ invoke_unix(operation, request, input)
35
+ else
36
+ invoke_local(operation, request, input)
37
+ end
38
+ return nil unless out && out.bytesize <= 16 * 1024
39
+ response = JSON.parse(out)
40
+ return nil unless valid_response?(response, request)
41
+ {"outcome" => response.fetch("outcome"), "evidence" => response.fetch("evidence"),
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.
47
+ nil
48
+ end
49
+
50
+ private
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.
55
+ def invoke_local(operation, request, input)
56
+ unless Process.uid == operation.fetch("executor_uid") && Process.euid == operation.fetch("executor_uid")
57
+ raise SecurityError, "current OS identity is not the configured executor"
58
+ end
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
78
+ end
79
+ out if out && out.bytesize <= 16 * 1024 && status&.success?
80
+ end
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
+
95
+ def invoke_unix(operation, request, input)
96
+ path = operation.fetch("socket_path")
97
+ stat = File.lstat(path)
98
+ unless stat.socket? && stat.uid == operation.fetch("executor_uid") && (stat.mode & 0o002).zero?
99
+ raise SecurityError, "service socket owner or permissions do not match the executor"
100
+ end
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
111
+ end
112
+ end
113
+
114
+ def valid_response?(response, request)
115
+ response.is_a?(Hash) && response.keys.sort == %w[evidence input_digest outcome request_id] &&
116
+ response["request_id"] == request["request_id"] &&
117
+ response["input_digest"] == request["input_digest"] &&
118
+ %w[succeeded failed].include?(response["outcome"]) &&
119
+ response["evidence"].is_a?(Array) && !response["evidence"].empty? &&
120
+ response["evidence"].all? do |item|
121
+ item.is_a?(Hash) && item.keys.sort == %w[ref sha256] &&
122
+ item["ref"].is_a?(String) && item["ref"].match?(/\A[a-zA-Z0-9_.:\/-]{1,256}\z/) &&
123
+ item["sha256"].is_a?(String) && item["sha256"].match?(/\A[0-9a-f]{64}\z/)
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "time"
5
+
6
+ module Ace
7
+ module Lab
8
+ module Molecules
9
+ # Deployment-owned operation and authorization facts. The caller may
10
+ # name a reference but cannot define its scope or executor binding.
11
+ class ServicePolicy
12
+ NAME = /\A[a-z][a-z0-9-]{0,63}\z/
13
+ ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
14
+
15
+ def initialize(document)
16
+ @operations = document.fetch("operations", {})
17
+ @authorizations = document.fetch("authorizations", {})
18
+ unless @operations.is_a?(Hash) && @authorizations.is_a?(Hash)
19
+ raise Ace::Lab::InvalidConfigurationError, "trusted service policy must contain mappings"
20
+ end
21
+ end
22
+
23
+ def operation!(name, project:, service_id:)
24
+ raise ArgumentError, "invalid operation name" unless name.to_s.match?(NAME)
25
+ operation = @operations[name]
26
+ raise SecurityError, "operation is not configured" unless operation.is_a?(Hash)
27
+ unless operation["project"] == project && operation["service_id"] == service_id
28
+ raise SecurityError, "operation does not authorize the selected service in this project"
29
+ end
30
+ transport = operation.fetch("transport", "local")
31
+ unless %w[local unix].include?(transport)
32
+ raise Ace::Lab::InvalidConfigurationError, "configured service transport is invalid"
33
+ end
34
+ if transport == "local"
35
+ argv = operation["argv"]
36
+ unless argv.is_a?(Array) && !argv.empty? && argv.all? { |v| v.is_a?(String) && !v.empty? } &&
37
+ argv.first.start_with?("/") && File.file?(argv.first) && File.executable?(argv.first)
38
+ raise Ace::Lab::InvalidConfigurationError, "configured operation needs a fixed executable argv"
39
+ end
40
+ elsif !operation["socket_path"].is_a?(String) || !operation["socket_path"].start_with?("/")
41
+ raise Ace::Lab::InvalidConfigurationError, "unix service needs an absolute socket path"
42
+ end
43
+ uid = operation["executor_uid"]
44
+ raise Ace::Lab::InvalidConfigurationError, "configured executor UID is invalid" unless uid.is_a?(Integer) && uid >= 0
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
53
+ expires_at = parse_time(operation["lease_expires_at"])
54
+ raise SecurityError, "executor lease has expired" if expires_at <= Time.now.utc
55
+ if operation["host_maintenance"] == true
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)
71
+ executable = operation.dig("argv", 0).to_s
72
+ unless deployment.start_with?("/") && sink.start_with?("/") && executable.start_with?("/") &&
73
+ !inside?(executable, deployment) && !inside?(sink, deployment)
74
+ raise Ace::Lab::InvalidConfigurationError,
75
+ "host maintenance needs executable and evidence sink outside the replaced deployment"
76
+ end
77
+ end
78
+ operation
79
+ end
80
+
81
+ def authorize!(reference, binding)
82
+ raise ArgumentError, "invalid authorization reference" unless reference.to_s.match?(ID)
83
+ decision = @authorizations[reference]
84
+ raise SecurityError, "authorization reference is unresolved" unless decision.is_a?(Hash)
85
+ required = %w[operation project_id assignment_id attempt_id input_digest target candidate_head caller_uid]
86
+ unless required.all? { |key| decision[key] == binding[key] }
87
+ raise SecurityError, "authorization does not match the exact service request"
88
+ end
89
+ raise SecurityError, "authorization has expired" if parse_time(decision["expires_at"]) <= Time.now.utc
90
+ decision
91
+ end
92
+
93
+ private
94
+
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"
103
+ end
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
113
+ raise Ace::Lab::InvalidConfigurationError, "configured executable is unavailable"
114
+ end
115
+
116
+ def inside?(path, root)
117
+ expanded = File.expand_path(path)
118
+ base = File.expand_path(root)
119
+ expanded == base || expanded.start_with?(base + File::SEPARATOR)
120
+ end
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
+
164
+ def parse_time(value)
165
+ Time.iso8601(value.to_s)
166
+ rescue ArgumentError
167
+ raise Ace::Lab::InvalidConfigurationError, "trusted policy has invalid expiry"
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "open3"
6
+ require "ace/assign"
7
+
8
+ module Ace
9
+ module Lab
10
+ module Organisms
11
+ # Coordinates the generic request contract. ace-assign owns the durable
12
+ # claim and receipt; this class never writes evidence files directly.
13
+ class ServiceRequestService
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)
21
+ @topology = topology || TopologyService.from_config
22
+ @policy = policy
23
+ @repo_root = repo_root || git_toplevel
24
+ @coordinator = coordinator ||
25
+ Ace::Assign::Organisms::AttemptCoordinator.new(repo_root: @repo_root)
26
+ @executor = executor || Molecules::ServiceExecutor.new
27
+ end
28
+
29
+ def request(project:, assignment:, attempt:, operation:, input_path:, authorization:, request_id:, dry_run: false)
30
+ {project: project, assignment: assignment, attempt: attempt, request: request_id}.each do |name, value|
31
+ raise ArgumentError, "invalid #{name} ID" unless value.is_a?(String) &&
32
+ value.match?(Molecules::ServicePolicy::ID)
33
+ end
34
+ input = Atoms::ServiceInput.load(input_path)
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
68
+ route = @topology.route(project: project, capability: operation)
69
+ return route.envelope unless route.ok?
70
+ service_id = route.data.fetch("entry").fetch("id")
71
+ trusted = Molecules::GrantResolver.trusted_document(Ace::Lab.authorization_path) unless @policy
72
+ policy = @policy || Molecules::ServicePolicy.new(trusted)
73
+ head = current_head
74
+ binding = {"request_id" => request_id, "assignment_id" => assignment, "attempt_id" => attempt,
75
+ "project_id" => project, "operation" => operation,
76
+ "input_digest" => Atoms::ServiceInput.digest(input), "target" => target,
77
+ "candidate_head" => head, "caller_uid" => Process.uid}
78
+ binding["authorization"] = authorization
79
+ binding["service_id"] = service_id
80
+ binding["caller_uid"] = Process.uid
81
+ validate_attempt!(binding)
82
+ begin
83
+ operation_policy = policy.operation!(operation, project: project, service_id: service_id)
84
+ policy.authorize!(authorization, binding)
85
+ rescue SecurityError, Ace::Lab::InvalidConfigurationError
86
+ @coordinator.reject_service_request(binding, reason: "policy_rejected") unless dry_run
87
+ raise
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
103
+
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)
110
+ preview = {"outcome" => "accepted", "dry_run" => true, "request_id" => request_id,
111
+ "project" => project, "operation" => operation, "service_id" => service_id,
112
+ "target" => target, "candidate_head" => head}
113
+ return {"status" => "ok", "data" => preview}
114
+ end
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.
119
+ claimed = @coordinator.claim_service_request(binding)
120
+ return result(claimed) unless claimed["state"] == "uncertain" && claimed["journal_commit"]
121
+
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
135
+ return result(@coordinator.service_request_status(request_id)) if receipt.nil?
136
+
137
+ state = receipt.fetch("outcome")
138
+ full_receipt = Models::ServiceReceipt.build(binding, receipt)
139
+ result(@coordinator.transition_service_request(request_id, state: state, receipt: full_receipt))
140
+ rescue ArgumentError => e
141
+ failure("invalid_input", e.message)
142
+ rescue SecurityError => e
143
+ failure("unauthorized", e.message)
144
+ rescue Ace::Lab::InvalidConfigurationError => e
145
+ failure("invalid_configuration", e.message)
146
+ rescue Ace::Assign::AttemptErrors::Conflict => e
147
+ failure("conflict", e.message)
148
+ rescue Ace::Assign::AttemptErrors::NotFound, Ace::Assign::AttemptErrors::ReceiptRejected => e
149
+ failure("invalid_attempt", e.message)
150
+ rescue Ace::Assign::AttemptErrors::InvalidState => e
151
+ failure("invalid_attempt", e.message)
152
+ rescue Ace::Assign::AttemptErrors::EvidenceUnavailable => e
153
+ failure("evidence_unavailable", e.message)
154
+ end
155
+
156
+ def status(request_id:)
157
+ request = @coordinator.service_request_status(request_id)
158
+ return failure("missing", "service request not found") unless request
159
+ return failure("unauthorized", "caller does not own the request") unless request["caller_uid"] == Process.uid
160
+ visibility = @topology.services(project: request["project_id"])
161
+ return visibility.envelope unless visibility.ok?
162
+ result(request)
163
+ rescue ArgumentError => e
164
+ failure("invalid_input", e.message)
165
+ rescue Ace::Assign::AttemptErrors::EvidenceUnavailable => e
166
+ failure("evidence_unavailable", e.message)
167
+ end
168
+
169
+ private
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
+
219
+ def validate_attempt!(binding)
220
+ attempt = @coordinator.service_attempt(binding)
221
+ unless attempt.candidate_head.nil? || attempt.candidate_head == binding["candidate_head"]
222
+ raise SecurityError, "request is not bound to an active managed attempt"
223
+ end
224
+ end
225
+
226
+ def current_head
227
+ out, _stderr, status = Open3.capture3("git", "rev-parse", "HEAD", chdir: @repo_root)
228
+ raise SecurityError, "candidate head is unavailable" unless status.success?
229
+ out.strip
230
+ end
231
+
232
+ def result(request)
233
+ public = request.slice("request_id", "assignment_id", "attempt_id", "project_id", "operation",
234
+ "input_digest", "target", "candidate_head", "service_id", "state", "receipt")
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)}
239
+ end
240
+
241
+ def failure(code, message)
242
+ {"status" => "error", "error" => {"code" => code, "message" => message}}
243
+ end
244
+ end
245
+ end
246
+ end
247
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Lab
5
- VERSION = "0.1.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
data/lib/ace/lab.rb CHANGED
@@ -16,16 +16,22 @@ end
16
16
  require_relative "lab/atoms/topology_schema"
17
17
  require_relative "lab/atoms/binding_freshness"
18
18
  require_relative "lab/atoms/public_projection"
19
+ require_relative "lab/atoms/service_input"
20
+ require_relative "lab/models/service_receipt"
19
21
  require_relative "lab/models/runtime_binding"
20
22
  require_relative "lab/models/topology_entry"
21
23
  require_relative "lab/models/query_result"
22
24
  require_relative "lab/molecules/topology_loader"
23
25
  require_relative "lab/molecules/caller_authorizer"
24
26
  require_relative "lab/molecules/grant_resolver"
27
+ require_relative "lab/molecules/hitl_authorizer"
28
+ require_relative "lab/molecules/service_policy"
29
+ require_relative "lab/molecules/service_executor"
25
30
  require_relative "lab/molecules/inventory_query"
26
31
  require_relative "lab/molecules/exact_resolver"
27
32
  require_relative "lab/molecules/capability_router"
28
33
  require_relative "lab/organisms/topology_service"
34
+ require_relative "lab/organisms/service_request_service"
29
35
  require_relative "lab/cli"
30
36
 
31
37
  module Ace
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.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-09-29 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
@@ -51,6 +51,20 @@ dependencies:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
53
  version: '0.18'
54
+ - !ruby/object:Gem::Dependency
55
+ name: ace-assign
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.61'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.61'
54
68
  - !ruby/object:Gem::Dependency
55
69
  name: ace-support-test-helpers
56
70
  requirement: !ruby/object:Gem::Requirement
@@ -155,23 +169,30 @@ files:
155
169
  - lib/ace/lab.rb
156
170
  - lib/ace/lab/atoms/binding_freshness.rb
157
171
  - lib/ace/lab/atoms/public_projection.rb
172
+ - lib/ace/lab/atoms/service_input.rb
158
173
  - lib/ace/lab/atoms/topology_schema.rb
159
174
  - lib/ace/lab/cli.rb
160
175
  - lib/ace/lab/cli/commands/agents.rb
161
176
  - lib/ace/lab/cli/commands/projects.rb
162
177
  - lib/ace/lab/cli/commands/resolve.rb
163
178
  - lib/ace/lab/cli/commands/route.rb
179
+ - lib/ace/lab/cli/commands/service.rb
164
180
  - lib/ace/lab/cli/commands/services.rb
165
181
  - lib/ace/lab/cli/commands/support.rb
166
182
  - lib/ace/lab/models/query_result.rb
167
183
  - lib/ace/lab/models/runtime_binding.rb
184
+ - lib/ace/lab/models/service_receipt.rb
168
185
  - lib/ace/lab/models/topology_entry.rb
169
186
  - lib/ace/lab/molecules/caller_authorizer.rb
170
187
  - lib/ace/lab/molecules/capability_router.rb
171
188
  - lib/ace/lab/molecules/exact_resolver.rb
172
189
  - lib/ace/lab/molecules/grant_resolver.rb
190
+ - lib/ace/lab/molecules/hitl_authorizer.rb
173
191
  - lib/ace/lab/molecules/inventory_query.rb
192
+ - lib/ace/lab/molecules/service_executor.rb
193
+ - lib/ace/lab/molecules/service_policy.rb
174
194
  - lib/ace/lab/molecules/topology_loader.rb
195
+ - lib/ace/lab/organisms/service_request_service.rb
175
196
  - lib/ace/lab/organisms/topology_service.rb
176
197
  - lib/ace/lab/version.rb
177
198
  homepage: https://github.com/cs3b/ace