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 +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +26 -14
- data/docs/usage.md +116 -10
- data/lib/ace/lab/atoms/service_input.rb +80 -0
- data/lib/ace/lab/cli/commands/service.rb +68 -0
- data/lib/ace/lab/cli.rb +6 -1
- data/lib/ace/lab/models/service_receipt.rb +18 -0
- data/lib/ace/lab/molecules/grant_resolver.rb +16 -0
- data/lib/ace/lab/molecules/hitl_authorizer.rb +63 -0
- data/lib/ace/lab/molecules/service_executor.rb +129 -0
- data/lib/ace/lab/molecules/service_policy.rb +172 -0
- data/lib/ace/lab/organisms/service_request_service.rb +247 -0
- data/lib/ace/lab/version.rb +1 -1
- data/lib/ace/lab.rb +6 -0
- metadata +23 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 521f7150bb3da5fcc12c475b5c2fce6c78808e78315e05731b43dc6632ffa599
|
|
4
|
+
data.tar.gz: 32a8f1106ff9321c5e6971411be50fad60497c770d7fcb38d19623e270700258
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
4
|
-
|
|
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
|
|
9
|
-
|
|
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`
|
|
21
|
-
not group/world-writable, verified at every query
|
|
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
|
|
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
|
-
- **
|
|
108
|
+
- **Execution evidence is separate from topology.** `ace-lab service` uses a
|
|
96
109
|
|
|
97
|
-
|
|
98
|
-
|
|
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`
|
|
103
|
-
|
|
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
|
|
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
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
10
|
-
|
|
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
|
|
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
|
|
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
|
|
281
|
+
- No Works or scheduling. Service request claims and receipts use
|
|
176
282
|
|
|
177
|
-
`
|
|
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
|
data/lib/ace/lab/version.rb
CHANGED
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.
|
|
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-
|
|
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
|