ace-lab 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 54eee52265f20d22376fcdf7ac0a748be0c8ec5d7400259310a1a5a37d2f373e
4
+ data.tar.gz: e9b3746081e8790a2764f8dcbbd54650ddb3a23890cdb653c689c5a5d6ad7d5c
5
+ SHA512:
6
+ metadata.gz: 78d706d9b60416afdd98a12bbf98dfe90a12e2cd4105c29f3bab5b177dcdea70f8ac0753e35926107790afbdd1fc03c9e792b9c4fd85a45b1dac716122032060
7
+ data.tar.gz: 646f3f2fae98bc011fabf589a1175c6248f36cd4cd89a7dc2172e0010287affbb16bebe4dc33ce8af5b53e263d2743d1af7c4410c2a307c0e08b215a57aad18f
@@ -0,0 +1,41 @@
1
+ # ace-lab default configuration
2
+ # Override in ~/.ace/lab/config.yml or .ace/lab/config.yml
3
+ #
4
+ # Deployed topology values are owned by the lab-config repository; this
5
+ # default ships a deliberately empty, safe structure so a fresh install
6
+ # resolves to "no topology" instead of guessed values.
7
+ #
8
+ # AUTHORIZATION GRANTS DO NOT LIVE HERE. Caller-writable cascade documents
9
+ # must never define who may see what (review round 4, F3). Grants come from
10
+ # the single deployment-controlled file /etc/lab/ace-lab/authorization.yml —
11
+ # a fixed path; the file and every directory on its real path must be
12
+ # root-owned and not group/world-writable, or queries fail closed:
13
+ #
14
+ # { "principals": { "<verified-local-uid>": { "projects": ["project-id"] } } }
15
+ #
16
+ # Topology schema (version 1):
17
+ # topology:
18
+ # projects:
19
+ # - { id: project-id, label: Display name }
20
+ # agents:
21
+ # - id: agent-id
22
+ # project: project-id
23
+ # role: planner
24
+ # capabilities: ["planning"]
25
+ # binding: { kind: runtime, state: active, instance_id: iid,
26
+ # attested_instance_id: iid }
27
+ # services:
28
+ # - id: service-id
29
+ # project: project-id
30
+ # capabilities: ["search"]
31
+ # default_for: ["search"]
32
+ # endpoint: { kind: http, url: "https://service.example/internal" }
33
+ # binding: { kind: service, state: active, instance_id: iid,
34
+ # attested_instance_id: iid }
35
+
36
+ schema_version: 1
37
+
38
+ topology:
39
+ projects: []
40
+ agents: []
41
+ services: []
data/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-09-28
9
+
10
+ ### Added
11
+
12
+ - Topology/routing CLI (`ace-lab`) addressing projects, agents, and services by
13
+ stable IDs: `projects`, `agents`, `services`, `resolve`, `route` commands with
14
+ deterministic JSON output (`--format json`).
15
+ - ADR-022 configuration contract for Lab topology (`schema_version: 1`) with
16
+ validated stable IDs, project references, capability normalization, binding
17
+ attestation fields, and verified-local-principal authorization mapping.
18
+ - Classified query outcomes: `missing`, `ambiguous`, `stale`, `unauthorized`,
19
+ `invalid_configuration`.
20
+ - Allowlist-based public projection; tokens, auth-file paths, endpoint
21
+ credentials, URL userinfo, query parameters, fragments, and raw pane/session
22
+ identifiers never appear in public output.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Michal Czyz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,110 @@
1
+ ## ace-lab
2
+
3
+ Topology and routing CLI for the Lab: address projects, agents, and services
4
+ by stable IDs. Part of ACE (Agentic Coding Environment).
5
+
6
+ `ace-lab` answers one question -- *what is the stable ID of the project, agent,
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.
10
+
11
+ ### Install
12
+
13
+ gem install ace-lab
14
+
15
+ ### Usage
16
+
17
+ Configure topology via the ADR-022 cascade (`~/.ace/lab/config.yml` or
18
+ `.ace/lab/config.yml`; deployed values are owned by the `lab-config`
19
+ 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
+ caller-writable cascade and never at a caller-selected location:
23
+
24
+ ```yaml
25
+ # .ace/lab/config.yml — topology only
26
+ schema_version: 1
27
+ topology:
28
+ projects:
29
+ - { id: atlas, label: Atlas platform }
30
+ agents:
31
+ - id: atlas-planner
32
+ project: atlas
33
+ role: planner
34
+ capabilities: [planning]
35
+ binding: { kind: runtime, state: active, instance_id: i1, attested_instance_id: i1 }
36
+ services:
37
+ - id: atlas-search
38
+ project: atlas
39
+ capabilities: [search]
40
+ default_for: [search]
41
+ endpoint: { kind: http, url: "https://search.example.internal/query?token=secret" }
42
+ binding: { kind: service, state: active, instance_id: i2, attested_instance_id: i2 }
43
+ ```
44
+
45
+ ```yaml
46
+ # trusted grants file (deployment-owned)
47
+ principals:
48
+ "<verified-local-uid>": { projects: ["atlas"] }
49
+ ```
50
+
51
+ Query it:
52
+
53
+ ace-lab projects --format json
54
+ ace-lab agents --project atlas --format json
55
+ ace-lab services --project atlas --format json
56
+ ace-lab resolve --id atlas-planner --format json
57
+ ace-lab route --project atlas --capability search --format json
58
+
59
+ Every command prints one deterministic JSON document:
60
+
61
+ {"status":"ok","data":{"entry":{"id":"atlas-search","project":"atlas",
62
+ "capabilities":["search"],"default_for":["search"],
63
+ "binding":{"kind":"service","state":"available",
64
+ "endpoint":{"kind":"http","url":"https://search.example.internal"}}}}}
65
+
66
+ Failures are classified: `missing`, `ambiguous`, `stale`, `unauthorized`,
67
+ `invalid_configuration` -- for example a replaced agent process:
68
+
69
+ {"status":"error","error":{"code":"stale","message":"stable ID
70
+ \"atlas-planner\" has a stale runtime binding; ...","id":"atlas-planner",
71
+ "project":"atlas"}}
72
+
73
+ ### Guarantees
74
+
75
+ - **Stable IDs are canonical.** Labels are display values and never resolve.
76
+
77
+ Exact match or a classified `missing` error.
78
+
79
+ - **Public output never leaks.** Tokens, auth-file paths, endpoint userinfo,
80
+
81
+ query parameters, fragments, pane/session identifiers, and instance
82
+ identities are excluded by an allowlist projection, not field redaction.
83
+
84
+ - **Authorization comes from the verified local process owner**, matched
85
+
86
+ against configured principals. There is no `--role` or `--principal` flag;
87
+ identity flags are rejected outright.
88
+
89
+ - **Routing is project-local.** Candidates are capable services of the
90
+
91
+ requested project with fresh (active + attested) bindings. Zero candidates
92
+ are `missing`; several equal candidates need a configured `default_for`, or
93
+ the result is `ambiguous`. Another project's service is never picked.
94
+
95
+ - **Read-only by contract.** No Lab invocation, no execution state, no
96
+
97
+ scheduling, no credentials -- service invocation and execution state belong
98
+ to separate tools.
99
+
100
+ ### Non-goals
101
+
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`
105
+ (`8wl.t.gad`); this package defines and validates the schema.
106
+
107
+ ### Development
108
+
109
+ bundle install
110
+ ace-test ace-lab # or: bundle exec rake test from ace-lab/
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test" << "lib"
8
+ t.test_files = FileList["test/**/*_test.rb"]
9
+ end
10
+
11
+ task spec: :test
12
+ task default: :test
data/docs/usage.md ADDED
@@ -0,0 +1,178 @@
1
+ # ace-lab usage
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.
6
+
7
+ ## Commands
8
+
9
+ All commands accept `--format json` (the only supported format; anything else
10
+ is rejected) and `-q/--quiet` (suppress stdout; exit semantics unchanged).
11
+
12
+ ### `ace-lab projects`
13
+
14
+ List projects visible to the verified caller.
15
+
16
+ $ ace-lab projects --format json
17
+ {"status":"ok","data":{"projects":[{"id":"atlas","label":"Atlas platform"},{"id":"borealis"}]}}
18
+
19
+ A caller with no configured principal receives:
20
+
21
+ {"status":"error","error":{"code":"unauthorized","message":"caller has no configured authorization for any lab project"}}
22
+
23
+ ### `ace-lab agents --project PROJECT`
24
+
25
+ List agents in one project.
26
+
27
+ $ ace-lab agents --project atlas --format json
28
+ {"status":"ok","data":{"agents":[{"id":"atlas-planner","project":"atlas","role":"planner","capabilities":["planning"],"binding":{"kind":"runtime","state":"available"}}]}}
29
+
30
+ `binding.state` is `available` when the configured runtime binding is active
31
+ and its instance identity matches the attestation; otherwise `stale`.
32
+
33
+ ### `ace-lab services --project PROJECT`
34
+
35
+ List services in one project.
36
+
37
+ $ ace-lab services --project atlas --format json
38
+ {"status":"ok","data":{"services":[{"id":"atlas-search","project":"atlas","capabilities":["search"],"default_for":["search"],"binding":{"kind":"service","state":"available","endpoint":{"kind":"http","url":"https://search.example.internal:8443"}}}]}}
39
+
40
+ The endpoint is a sanitized identity: scheme, host, and explicit port only.
41
+
42
+ ### `ace-lab resolve --id ID`
43
+
44
+ Resolve one entry by exact stable ID.
45
+
46
+ $ ace-lab resolve --id atlas-planner --format json
47
+ {"status":"ok","data":{"entry":{"id":"atlas-planner","project":"atlas","role":"planner","capabilities":["planning"],"binding":{"kind":"runtime","state":"available"}}}}
48
+
49
+ Labels are display values and never resolve: `resolve "Atlas platform"` is a
50
+ `missing` error.
51
+
52
+ ### `ace-lab route --project PROJECT --capability CAPABILITY`
53
+
54
+ Select a configured capable service in the requested project.
55
+
56
+ $ ace-lab route --project atlas --capability search --format json
57
+ {"status":"ok","data":{"entry":{"id":"atlas-search","project":"atlas","capabilities":["search"],"default_for":["search"],"binding":{"kind":"service","state":"available","endpoint":{"kind":"http","url":"https://search.example.internal:8443"}}}}}
58
+
59
+ Routing never invokes the service and never grants credentials.
60
+
61
+ ## Error semantics
62
+
63
+ Errors are one deterministic JSON document on stdout plus a non-zero exit.
64
+
65
+ | Code | Meaning |
66
+ |------|---------|
67
+ | `missing` | No entry with that stable ID, or no capable service in the project |
68
+ | `ambiguous` | Several equally capable services without a configured `default_for` |
69
+ | `stale` | Binding is inactive, unattested, or the instance identity was replaced |
70
+ | `unauthorized` | Caller's verified local identity has no principal for the project |
71
+ | `invalid_configuration` | Topology config violates the schema; message is actionable |
72
+
73
+ Example:
74
+
75
+ $ ace-lab resolve --id atlas-coder --format json; echo $?
76
+ {"status":"error","error":{"code":"stale","message":"stable ID \"atlas-coder\" has a stale runtime binding; a replaced process must re-attest before routing","id":"atlas-coder","project":"atlas"}}
77
+ 1
78
+
79
+ ## Configuration
80
+
81
+ Schema version 1, resolved through the ADR-022 cascade (project `.ace/lab/`
82
+ over user `~/.ace/lab/` over gem defaults; gem defaults are an empty, safe
83
+ topology). Cascade documents carry **topology only**:
84
+
85
+ ```yaml
86
+ schema_version: 1
87
+ topology:
88
+ projects:
89
+ - id: atlas # globally unique across all entries
90
+ label: Display name # display only; never resolves
91
+ agents:
92
+ - id: atlas-planner
93
+ project: atlas # must reference an existing project
94
+ role: planner
95
+ capabilities: [planning] # non-empty; normalized (stripped, lowercased)
96
+ binding:
97
+ kind: runtime # agents bind to runtimes, services to services
98
+ state: active # routeable only when active
99
+ instance_id: i1 # runtime identity fact
100
+ attested_instance_id: i1 # must equal instance_id to be fresh
101
+ services:
102
+ - id: atlas-search
103
+ project: atlas
104
+ capabilities: [search]
105
+ default_for: [search] # exactly one default may win among candidates
106
+ endpoint:
107
+ kind: http # http or https
108
+ url: "https://search.example.internal/query?token=secret" # stored, never published
109
+ binding: { kind: service, state: active, instance_id: i2, attested_instance_id: i2 }
110
+ ```
111
+
112
+ ### Authorization grants
113
+
114
+ Grants never live in the cascade: project and user documents are
115
+ caller-writable, so an `authorization` section there is rejected as
116
+ `invalid_configuration`. Grants come from a single deployment-controlled
117
+ file at the **fixed path** `/etc/lab/ace-lab/authorization.yml` (installed
118
+ by the `lab-config` deployment). The location is not caller-selectable —
119
+ there is no flag or environment override. At every query the tool verifies
120
+ the file and every directory on its real path are root-owned and not
121
+ group/world-writable, and opens the file `O_NOFOLLOW`; any failed
122
+ verification fails closed:
123
+
124
+ ```yaml
125
+ principals:
126
+ "<verified-local-uid>": # passwd username or numeric uid of the caller
127
+ projects: ["atlas"]
128
+ ```
129
+
130
+ The grants file is machine-global: it is validated structurally only, so a
131
+ principal may reference projects absent from the current directory's
132
+ 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
134
+ passwd username is authorized solely through its uid, so it can never
135
+ consume a different account's numeric-uid grant.
136
+
137
+ A missing trusted file means nobody is authorized (fail closed).
138
+
139
+ Validation of the topology cascade rejects duplicate IDs (globally unique
140
+ across projects, agents, services), unknown project references, malformed
141
+ capabilities, defaults for undeclared capabilities, unusable endpoints
142
+ (absolute http(s) URL with a host and a port in 1–65535; `endpoint.kind` is
143
+ `http` or `https`), unsupported binding kinds, and principals referencing
144
+ unknown projects.
145
+
146
+ **Error messages are value-free by design:** validation runs before
147
+ authorization, so messages use positional field locations
148
+ (`topology.agents[0].project references an unknown project`) and never echo
149
+ configured IDs, project names, or principal names — configuration defects
150
+ cannot disclose topology to unauthorized callers.
151
+
152
+ **Ownership:** deployed topology and authorization values are maintained by
153
+ the `lab-config` repository (`8wl.t.gad`). `ace-lab` defines the schema and
154
+ reads; it never provisions.
155
+
156
+ ## Guarantees and boundaries
157
+
158
+ - Public output is built from a strict allowlist: `id`, `project`, `label`,
159
+
160
+ `role`, `capabilities`, `default_for`, binding `kind` + availability state,
161
+ and a sanitized endpoint identity. Tokens, auth-file paths, URL userinfo,
162
+ paths, query parameters, fragments, pane/session IDs, and instance
163
+ identities never appear in any command output.
164
+
165
+ - Caller identity comes from the verified local process owner
166
+
167
+ (username or uid). `--role`, `--principal`, and `--caller` flags are
168
+ rejected: authorization is a property of the caller, not an input.
169
+
170
+ - A replaced process keeps its stable ID; until re-attested, queries
171
+
172
+ classify it `stale` -- an explicit unavailable result, never a routeable
173
+ answer.
174
+
175
+ - No Works, assignment state, or scheduling. Service invocation belongs to
176
+
177
+ `8wr.t.qjx`; execution state to `8wr.t.qjl`. The Lab execution binary
178
+ (`/usr/local/bin/lab`) is never invoked or required.
data/exe/ace-lab ADDED
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "ace/lab"
5
+
6
+ # No args -> show help
7
+ args = ARGV.empty? ? ["--help"] : ARGV
8
+
9
+ # Start CLI with exception-based exit code handling (per ADR-023).
10
+ # No bundler/setup here: an installed gem must run without a Gemfile;
11
+ # use `bundle exec` in development to resolve workspace sources.
12
+ begin
13
+ exit_code = Ace::Lab::CLI.start(args)
14
+ exit(exit_code) if exit_code.is_a?(Integer) && exit_code.nonzero?
15
+ rescue Ace::Support::Cli::Error => e
16
+ # e.to_s carries the documented "Error: " prefix (Ace::Support::Cli::Error)
17
+ warn e.to_s # standard:disable Lint/RedundantStringCoercion
18
+ exit(e.exit_code)
19
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Lab
5
+ module Atoms
6
+ # Pure freshness classification for a runtime binding (spec 8wq.t.1w4).
7
+ # A binding is fresh only when it is explicitly active AND the configured
8
+ # instance identity exactly matches its attestation. Absent, inactive,
9
+ # or mismatched identities classify as stale — never guessed fresh.
10
+ module BindingFreshness
11
+ FRESH = "fresh"
12
+ STALE = "stale"
13
+
14
+ class << self
15
+ # @param binding [Ace::Lab::Models::RuntimeBinding, nil]
16
+ # @return [String] FRESH or STALE
17
+ def classify(binding)
18
+ return STALE if binding.nil?
19
+ return STALE unless binding.state == "active"
20
+ return STALE if binding.instance_id.nil? || binding.instance_id.empty?
21
+ return STALE unless binding.instance_id == binding.attested_instance_id
22
+
23
+ FRESH
24
+ end
25
+
26
+ def fresh?(binding)
27
+ classify(binding) == FRESH
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Ace
6
+ module Lab
7
+ module Atoms
8
+ # Allowlist-based public projection of topology entries (spec 8wq.t.1w4).
9
+ # Public output is constructed exclusively from the fields below, so raw
10
+ # configuration values can never leak: no tokens, auth-file paths,
11
+ # endpoint credentials, URL userinfo, path, query parameters, fragments,
12
+ # pane/session identifiers, or instance identities.
13
+ module PublicProjection
14
+ class << self
15
+ # @return [Hash] {id, label?}
16
+ def project(entry)
17
+ public_entry = {"id" => entry.id}
18
+ public_entry["label"] = entry.label if entry.label
19
+ public_entry
20
+ end
21
+
22
+ # @return [Hash] {id, project, label?, role, capabilities,
23
+ # binding: {kind, state}}
24
+ def agent(entry)
25
+ public_entry = {
26
+ "id" => entry.id,
27
+ "project" => entry.project,
28
+ "role" => entry.role,
29
+ "capabilities" => entry.capabilities,
30
+ "binding" => binding_summary(entry.binding)
31
+ }
32
+ public_entry["label"] = entry.label if entry.label
33
+ public_entry
34
+ end
35
+
36
+ # @return [Hash] {id, project, label?, capabilities, default_for,
37
+ # binding: {kind, state, endpoint: {kind, url?}}}
38
+ def service(entry)
39
+ public_entry = {
40
+ "id" => entry.id,
41
+ "project" => entry.project,
42
+ "capabilities" => entry.capabilities,
43
+ "default_for" => entry.default_for,
44
+ "binding" => binding_summary(entry.binding).merge(endpoint_summary(entry))
45
+ }
46
+ public_entry["label"] = entry.label if entry.label
47
+ public_entry
48
+ end
49
+
50
+ # Project an entry of any kind
51
+ def entry(entry)
52
+ case entry.kind
53
+ when "project" then project(entry)
54
+ when "agent" then agent(entry)
55
+ else service(entry)
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ # Availability state derived from freshness; never exposes
62
+ # instance identities or raw configured state
63
+ def binding_summary(binding)
64
+ {
65
+ "kind" => binding&.kind,
66
+ "state" => binding_freshness(binding)
67
+ }
68
+ end
69
+
70
+ def binding_freshness(binding)
71
+ fresh = Ace::Lab::Atoms::BindingFreshness.fresh?(binding)
72
+ fresh ? "available" : "stale"
73
+ end
74
+
75
+ # Safe endpoint identity: scheme, host, and explicit port only.
76
+ # Userinfo, path, query, and fragment are dropped by construction;
77
+ # an unparseable URL projects no endpoint identity at all.
78
+ def endpoint_summary(entry)
79
+ endpoint = entry.endpoint
80
+ return {} unless endpoint.is_a?(Hash)
81
+
82
+ {"endpoint" => {"kind" => endpoint["kind"], "url" => sanitize_url(endpoint["url"])}}
83
+ end
84
+
85
+ def sanitize_url(url)
86
+ uri = URI.parse(url)
87
+ return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
88
+ return nil if uri.host.nil? || uri.host.empty?
89
+
90
+ (uri.port == uri.default_port) ? "#{uri.scheme}://#{uri.host}" : "#{uri.scheme}://#{uri.host}:#{uri.port}"
91
+ rescue URI::Error, ArgumentError
92
+ nil
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end