ace-hitl 0.14.0 → 0.16.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/.ace-defaults/hitl/config.yml +0 -0
- data/.ace-defaults/nav/protocols/skill-sources/ace-hitl.yml +0 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-hitl.yml +0 -0
- data/CHANGELOG.md +20 -0
- data/Rakefile +0 -0
- data/docs/demo/ace-hitl-scope-and-answer.tape.yml +0 -0
- data/docs/demo/fixtures/demo-context.md +0 -0
- data/docs/usage.md +47 -2
- data/handbook/README.md +0 -0
- data/handbook/skills/as-hitl/SKILL.md +0 -0
- data/handbook/workflow-instructions/hitl.wf.md +0 -0
- data/lib/ace/hitl/atoms/hitl_effect_validator.rb +0 -0
- data/lib/ace/hitl/atoms/hitl_file_pattern.rb +0 -0
- data/lib/ace/hitl/atoms/hitl_id_formatter.rb +0 -0
- data/lib/ace/hitl/cli/commands/create.rb +0 -0
- data/lib/ace/hitl/cli/commands/list.rb +0 -0
- data/lib/ace/hitl/cli/commands/serve.rb +38 -2
- data/lib/ace/hitl/cli/commands/show.rb +0 -0
- data/lib/ace/hitl/lifecycle/client.rb +11 -151
- data/lib/ace/hitl/lifecycle/credential_path_policy.rb +183 -0
- data/lib/ace/hitl/lifecycle/frame_exchange.rb +78 -0
- data/lib/ace/hitl/lifecycle/frame_serving.rb +119 -0
- data/lib/ace/hitl/lifecycle/lifecycle_requests.rb +101 -0
- data/lib/ace/hitl/lifecycle/policy.rb +34 -3
- data/lib/ace/hitl/lifecycle/proposals.rb +12 -0
- data/lib/ace/hitl/lifecycle/remote_peer.rb +78 -0
- data/lib/ace/hitl/lifecycle/service.rb +33 -102
- data/lib/ace/hitl/lifecycle/store.rb +1 -0
- data/lib/ace/hitl/lifecycle/tls_access_policy.rb +50 -0
- data/lib/ace/hitl/lifecycle/tls_authority.rb +121 -0
- data/lib/ace/hitl/lifecycle/tls_credentials.rb +210 -0
- data/lib/ace/hitl/lifecycle/tls_material.rb +133 -0
- data/lib/ace/hitl/lifecycle/tls_project_scope.rb +68 -0
- data/lib/ace/hitl/lifecycle/tls_service.rb +242 -0
- data/lib/ace/hitl/lifecycle.rb +11 -0
- data/lib/ace/hitl/models/hitl_event.rb +0 -0
- data/lib/ace/hitl/molecules/hitl_answer_editor.rb +0 -0
- data/lib/ace/hitl/molecules/hitl_config_loader.rb +0 -0
- data/lib/ace/hitl/molecules/hitl_creator.rb +0 -0
- data/lib/ace/hitl/molecules/hitl_display_formatter.rb +0 -0
- data/lib/ace/hitl/molecules/hitl_loader.rb +0 -0
- data/lib/ace/hitl/molecules/hitl_resolver.rb +0 -0
- data/lib/ace/hitl/molecules/hitl_scanner.rb +0 -0
- data/lib/ace/hitl/molecules/resume_dispatcher.rb +0 -0
- data/lib/ace/hitl/molecules/worktree_scope_resolver.rb +0 -0
- data/lib/ace/hitl/proposals/policy.rb +1 -0
- data/lib/ace/hitl/providers/lab/assignment_binding.rb +20 -1
- data/lib/ace/hitl/version.rb +1 -1
- metadata +31 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6d2bae48cdaf02e617f52c254eb78f5a8dae316be06a8787d4282041c01eacc3
|
|
4
|
+
data.tar.gz: 81410404b3ad0e07900497a9f62594aba56aaf987ed2d816d208bcab112b096c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e5ad8fad6083826ca4f1fb6220d7bf87bd302482bbbe946cc93778bf83279d3cf728898e9f2c9bb286ebac9e98a278b5129ab3914d1ee7b354c2befd07400129
|
|
7
|
+
data.tar.gz: 1b0bae700322c0fae15a9f6ab81a7efd5faad863db9f729115909c95e66087be26b2217ad037e5e21ea3a7e7dc7bd418afa3dd1b01654f8f5fcda36007fd5b3d
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.16.0] - 2026-10-16
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Require explicit predelegation for sixteen-hour proposal decisions and bind ordinary project proposals to the selected canonical owner. Refresh project and owner grants at transitions and replay; compose existing Unix/TLS listeners under the authority lifetime.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Read TLS authority configuration through bounded protected descriptors, rejecting symlinks, unsafe ancestors, special files and concurrent replacement before startup. Declare the direct Runtime dependency used by this reader.
|
|
19
|
+
|
|
20
|
+
## [0.15.0] - 2026-10-14
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- Add `ace-hitl serve --tls-authority FILE` for the host/Incus mTLS crossing: a TLS 1.3-only loopback lifecycle authority with mutual X.509 identity (closed-form URI SANs, exact SHA-256 fingerprint pins, configured issuing CA, serverAuth/clientAuth EKU), fail-closed credential provisioning-layout enforcement before any socket opens, and remote peer-to-project binding that refuses pending flows, creates, and proposals for any non-bound project. Unix peer-credential serving is unchanged; shared frame/store logic is extracted without behavior change.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- Narrow `ace-hitl serve --group` to dedicated lifecycle groups: a missing group, broad or known-shared control groups (gid 0, `lab-control`), the legacy `/run/lab` directory, and directories containing a legacy `labd.sock` entry now refuse startup fail-closed before any endpoint is created.
|
|
29
|
+
|
|
10
30
|
## [0.14.0] - 2026-10-11
|
|
11
31
|
|
|
12
32
|
### Added
|
data/Rakefile
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|
data/docs/usage.md
CHANGED
|
@@ -358,8 +358,11 @@ The JSON proposal requires operation, target (`resource` and optional `artifact_
|
|
|
358
358
|
candidate_head, input_digest, context, options, recommendation and prerequisites; rationale
|
|
359
359
|
is optional. Contents are bounded and non-secret. Input digest uses the canonical service
|
|
360
360
|
input digest. Candidate head is separate from base head and the evidence journal commit.
|
|
361
|
-
|
|
362
|
-
|
|
361
|
+
Only operation classes explicitly delegated in trusted project policy are eligible
|
|
362
|
+
for sixteen-hour silence. Delegation must exist when the revision is created and
|
|
363
|
+
remain current at the deadline; a later grant cannot approve an older revision.
|
|
364
|
+
Without delegation the proposal still accepts an explicit correlated decision.
|
|
365
|
+
Fixed service scope, current head,
|
|
363
366
|
independently executed review/tests, bound running attempt and operation-specific OTP gates
|
|
364
367
|
still apply at execution. Proposal authorization never supplies credentials or runs a callback.
|
|
365
368
|
|
|
@@ -423,6 +426,8 @@ hitl:
|
|
|
423
426
|
service_uid: 1200
|
|
424
427
|
transport_uids: [1201]
|
|
425
428
|
proposal_uids: [1202]
|
|
429
|
+
silence_operations:
|
|
430
|
+
ace: [merge]
|
|
426
431
|
authorization:
|
|
427
432
|
principals:
|
|
428
433
|
"1201":
|
|
@@ -438,6 +443,11 @@ resolve silence. Missing role or project admission refuses the operation,
|
|
|
438
443
|
including direct library calls. This is a source configuration contract;
|
|
439
444
|
installed deployment adoption remains part of gad.8 acceptance.
|
|
440
445
|
|
|
446
|
+
`silence_operations` is an exact project-to-operation list, with no wildcards or
|
|
447
|
+
implicit publish/deploy/access grants. It never expands command or credential
|
|
448
|
+
authority. TLS authorities read the same trusted grants document selected by
|
|
449
|
+
`--grants-path`; certificate authentication alone grants no silence decision.
|
|
450
|
+
|
|
441
451
|
### Controlled installed proposal verification
|
|
442
452
|
|
|
443
453
|
From an ACE checkout with Ruby, tmux and the complete dependency archives cached locally:
|
|
@@ -472,3 +482,38 @@ Missing local dependency archives fail visibly without network fallback. A faile
|
|
|
472
482
|
consumer retains diagnostic artifacts; inspect `result.json` and `service.log`
|
|
473
483
|
before retrying. Each invocation retains a new run directory and requires a new
|
|
474
484
|
empty GEM_HOME.
|
|
485
|
+
|
|
486
|
+
## The host/Incus mTLS crossing (nh0-A)
|
|
487
|
+
|
|
488
|
+
`ace-hitl serve --tls-authority FILE` runs one lifecycle authority of
|
|
489
|
+
the shared-Hermes topology on a loopback TCP endpoint where the
|
|
490
|
+
project's Incus TCP proxy device connects. The authority config
|
|
491
|
+
(`ace.hitl.tls.authority/v1`, closed fields, root-owned runtime file
|
|
492
|
+
`root:<service-group> 0640`) owns the loopback listen address, the
|
|
493
|
+
project ID, the service account names and the server credential
|
|
494
|
+
reference — never secret bytes:
|
|
495
|
+
|
|
496
|
+
```json
|
|
497
|
+
{"schema": "ace.hitl.tls.authority/v1",
|
|
498
|
+
"project_id": "proj-a", "listen": "127.0.0.1", "port": 8443,
|
|
499
|
+
"service_user": "hitl-proj-a", "service_group": "hitl-proj-a",
|
|
500
|
+
"tls_server_credential": {
|
|
501
|
+
"certificate_file": "/etc/ace-hitl/proj-a/server.pem",
|
|
502
|
+
"private_key_file": "/etc/ace-hitl/proj-a/server-key.pem",
|
|
503
|
+
"ca_file": "/etc/ace-hitl/proj-a/ca.pem",
|
|
504
|
+
"server_uri_san": "urn:ace:hitl:proj-a:lifecycle-server",
|
|
505
|
+
"allowed_client_uri_san": "urn:ace:hitl:proj-a:hermes-client",
|
|
506
|
+
"allowed_client_certificate_sha256": "<64 lowercase hex>"}}
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
Startup enforces the exact provisioning layout (ancestors `root:root`
|
|
510
|
+
0755, project directory `root:<service-group>` 0750, private key
|
|
511
|
+
`<service-user>:<service-group>` 0400 and never hard-linked,
|
|
512
|
+
certificate/CA `root:root` 0444) BEFORE any socket opens, validates its
|
|
513
|
+
own certificate identity (single URI SAN, serverAuth EKU, key match),
|
|
514
|
+
speaks TLS 1.3 only, and admits exactly the configured client
|
|
515
|
+
SAN/fingerprint pair — bound to the authority's one project, with
|
|
516
|
+
pending flows and creates refused for any other project. Unix
|
|
517
|
+
peer-credential serving (`serve` without `--tls-authority`) remains the
|
|
518
|
+
same-runtime boundary. lab-config (gad.8/gad.b) owns accounts, file
|
|
519
|
+
provisioning, Incus proxy devices and the tailnet listeners.
|
data/handbook/README.md
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -19,15 +19,20 @@ module Ace
|
|
|
19
19
|
desc "Run the authenticated HITL store boundary service (peer-credential access)"
|
|
20
20
|
|
|
21
21
|
option :socket, type: :string, desc: "Boundary socket path (default: ACE_HITL_SOCKET or /run/lab/hitl.sock)"
|
|
22
|
-
option :group, type: :string,
|
|
22
|
+
option :group, type: :string,
|
|
23
|
+
desc: "Dedicated lifecycle group allowed to connect (required; root and lab-control are refused)"
|
|
23
24
|
option :store_root, type: :string, desc: "Private store root (default: ACE_HITL_STORE_ROOT or /run/lab/hitl)"
|
|
24
25
|
option :grants_path, type: :string, desc: "Trusted grants document (default: ACE_HITL_GRANTS_PATH or /etc/lab/ace-lab/authorization.yml)"
|
|
25
26
|
option :repo_root, type: :string, desc: "Candidate repository for the managed attempt authority"
|
|
27
|
+
option :tls_authority, type: :string, desc: "Serve the host/Incus mTLS crossing from this authority config (root:<service-group> 0640; loopback TCP + verified project client identity)"
|
|
26
28
|
|
|
27
29
|
option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
|
|
28
30
|
option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
|
|
29
31
|
|
|
30
32
|
def call(**options)
|
|
33
|
+
return serve_tls_authority(options) if options[:tls_authority]
|
|
34
|
+
|
|
35
|
+
group = dedicated_group(options)
|
|
31
36
|
policy = Providers::Lab.grants_policy(grants_path: grants_path(options))
|
|
32
37
|
unless policy.service_uid
|
|
33
38
|
raise_cli_error(
|
|
@@ -42,7 +47,7 @@ module Ace
|
|
|
42
47
|
binding: binding_policy,
|
|
43
48
|
policy: policy,
|
|
44
49
|
socket_path: socket_path(options),
|
|
45
|
-
group:
|
|
50
|
+
group: group,
|
|
46
51
|
logger: options[:verbose] ? ->(message) { warn message } : nil
|
|
47
52
|
)
|
|
48
53
|
service.run
|
|
@@ -52,6 +57,37 @@ module Ace
|
|
|
52
57
|
|
|
53
58
|
private
|
|
54
59
|
|
|
60
|
+
# The host/Incus crossing (spec 8xa.t.nh0): authority config,
|
|
61
|
+
# credential layout and TLS material are verified fail-closed
|
|
62
|
+
# BEFORE the loopback listener opens; the verified project-
|
|
63
|
+
# scoped client certificate is the only admitted peer.
|
|
64
|
+
def serve_tls_authority(options)
|
|
65
|
+
authority = Lifecycle::TlsAuthority.load(options[:tls_authority])
|
|
66
|
+
binding_policy = Providers::Lab.assignment_binding(repo_root: options[:repo_root])
|
|
67
|
+
service = Lifecycle::TlsService.new(
|
|
68
|
+
root: store_root(options),
|
|
69
|
+
binding: binding_policy,
|
|
70
|
+
project: authority.project_id,
|
|
71
|
+
credential: authority.credential,
|
|
72
|
+
decision_policy: Providers::Lab.grants_policy(grants_path: grants_path(options)),
|
|
73
|
+
ssl_context: authority.ssl_context!,
|
|
74
|
+
listen: authority.listen,
|
|
75
|
+
port: authority.port,
|
|
76
|
+
group: authority.service_group,
|
|
77
|
+
logger: options[:verbose] ? ->(message) { warn message } : nil
|
|
78
|
+
)
|
|
79
|
+
service.run
|
|
80
|
+
rescue Lifecycle::Error, Providers::ProviderUnavailableError => e
|
|
81
|
+
raise_cli_error(e.message)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def dedicated_group(options)
|
|
85
|
+
group = options[:group].to_s
|
|
86
|
+
return group unless group.empty?
|
|
87
|
+
|
|
88
|
+
raise_cli_error("--group is required and must name a dedicated lifecycle group")
|
|
89
|
+
end
|
|
90
|
+
|
|
55
91
|
def socket_path(options)
|
|
56
92
|
options[:socket] || ENV[Providers::Lab::SOCKET_PATH_ENV] || Providers::Lab::DEFAULT_SOCKET_PATH
|
|
57
93
|
end
|
|
File without changes
|
|
@@ -2,13 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
require "socket"
|
|
4
4
|
require_relative "protocol"
|
|
5
|
+
require_relative "frame_exchange"
|
|
6
|
+
require_relative "lifecycle_requests"
|
|
5
7
|
|
|
6
8
|
module Ace
|
|
7
9
|
module Hitl
|
|
8
10
|
module Lifecycle
|
|
9
|
-
# The authenticated client of the scoped store
|
|
10
|
-
# 8wq.t.34i). The CLI lifecycle operations run
|
|
11
|
-
# direct shared-store access is not a
|
|
11
|
+
# The authenticated Unix-socket client of the scoped store
|
|
12
|
+
# boundary (spec 8wq.t.34i). The CLI lifecycle operations run
|
|
13
|
+
# through this client; direct shared-store access is not a
|
|
14
|
+
# client-side option. Cross-runtime clients use the mutual-TLS
|
|
15
|
+
# Hermes client of the host/Incus crossing (spec 8xa.t.nh0)
|
|
16
|
+
# instead; both share the request surface and frame exchange.
|
|
12
17
|
#
|
|
13
18
|
# Both endpoint directions are authenticated:
|
|
14
19
|
# - the client authenticates the SERVICE: the socket must be a
|
|
@@ -21,43 +26,10 @@ module Ace
|
|
|
21
26
|
# classified lifecycle errors arrive from the service and are
|
|
22
27
|
# re-raised under their own classes.
|
|
23
28
|
class Client
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def proposal_create(**params)
|
|
27
|
-
request("proposal-create", params)
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def proposal_show(id, project:, history_after: 0)
|
|
31
|
-
request("proposal-show", {"id" => id, "project" => project, "history_after" => history_after})
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def proposal_revise(id, project:, expected_revision:, operation_id:, document:)
|
|
35
|
-
request("proposal-revise", {"id" => id, "project" => project, "expected_revision" => expected_revision, "operation_id" => operation_id, "document" => document})
|
|
36
|
-
end
|
|
29
|
+
include FrameExchange
|
|
30
|
+
include LifecycleRequests
|
|
37
31
|
|
|
38
|
-
|
|
39
|
-
request("proposal-ack", {"id" => id, "project" => project, "submitted_at" => submitted_at})
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def proposal_reply(id, project:, **params)
|
|
43
|
-
request("proposal-reply", {"id" => id, "project" => project}.merge(params))
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def proposal_reconcile(id, project:, checkpoint:)
|
|
47
|
-
request("proposal-reconcile", {"id" => id, "project" => project, "checkpoint" => checkpoint})
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def proposal_wake(project:, after: nil)
|
|
51
|
-
request("proposal-wake", {"project" => project, "after" => after}.compact)
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def proposal_due(project:, after: nil)
|
|
55
|
-
request("proposal-due", {"project" => project, "after" => after}.compact)
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def proposal_history(project:, query: "", after: nil)
|
|
59
|
-
request("proposal-history", {"project" => project, "query" => query, "after" => after}.compact)
|
|
60
|
-
end
|
|
32
|
+
attr_reader :socket_path, :service_uid
|
|
61
33
|
|
|
62
34
|
def initialize(socket_path:, service_uid:, deadline_seconds: Protocol::DEFAULT_DEADLINE_SECONDS)
|
|
63
35
|
@socket_path = socket_path.to_s
|
|
@@ -85,57 +57,6 @@ module Ace
|
|
|
85
57
|
raise TransportError, "HITL boundary request is not serializable (#{e.message})"
|
|
86
58
|
end
|
|
87
59
|
|
|
88
|
-
def create(deadline: nil, **params)
|
|
89
|
-
request("create", params.transform_keys(&:to_s), deadline: deadline)
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def read(id, deadline: nil)
|
|
93
|
-
request("read", {"id" => id}, deadline: deadline)
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
def deliver(id, answer)
|
|
97
|
-
request("deliver", {"id" => id, "answer" => answer.to_s})
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def consume(id, timeout: 0, operation: nil, native_delivery: false, deadline: nil)
|
|
101
|
-
params = {"id" => id, "timeout" => Integer(timeout)}
|
|
102
|
-
params["operation"] = operation if operation
|
|
103
|
-
params["native_delivery"] = true if native_delivery
|
|
104
|
-
request("consume", params, deadline: deadline)
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
def cancel(id, reason: "", deadline: nil)
|
|
108
|
-
request("cancel", {"id" => id, "reason" => reason.to_s}, deadline: deadline)
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
def pending(project: nil)
|
|
112
|
-
items = []
|
|
113
|
-
after = nil
|
|
114
|
-
loop do
|
|
115
|
-
page = pending_page(project: project, after: after)
|
|
116
|
-
items.concat(page.fetch("items"))
|
|
117
|
-
cursor = page.fetch("next")
|
|
118
|
-
break unless cursor
|
|
119
|
-
unless cursor.is_a?(String) && (after.nil? || cursor > after)
|
|
120
|
-
raise TransportError, "pending cursor did not advance"
|
|
121
|
-
end
|
|
122
|
-
after = cursor
|
|
123
|
-
end
|
|
124
|
-
items
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def pending_page(project: nil, after: nil)
|
|
128
|
-
request("pending", {"project" => project, "after" => after}.compact)
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
def states
|
|
132
|
-
request("states")
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
def ping
|
|
136
|
-
request("ping")
|
|
137
|
-
end
|
|
138
|
-
|
|
139
60
|
private
|
|
140
61
|
|
|
141
62
|
# The endpoint must be owned by the trusted service identity and
|
|
@@ -175,24 +96,6 @@ module Ace
|
|
|
175
96
|
socket.getpeereid
|
|
176
97
|
end
|
|
177
98
|
|
|
178
|
-
# A consume without a timeout waits indefinitely at the client
|
|
179
|
-
# boundary too; every bounded call gets a deadline slightly
|
|
180
|
-
# beyond its own wait so the server result wins the race.
|
|
181
|
-
def request_deadline(op, params, selected)
|
|
182
|
-
if !selected.nil? && (!selected.is_a?(Numeric) || !selected.finite?)
|
|
183
|
-
raise TransportError, "HITL boundary deadline is invalid"
|
|
184
|
-
end
|
|
185
|
-
local = deadline_for(op, params)
|
|
186
|
-
[selected, local].compact.min
|
|
187
|
-
end
|
|
188
|
-
|
|
189
|
-
def await!(socket, direction, deadline)
|
|
190
|
-
remaining = deadline && deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
191
|
-
raise TransportError, "HITL boundary deadline exceeded" if remaining && remaining <= 0
|
|
192
|
-
readable, writable = direction == :read ? [[socket], nil] : [nil, [socket]]
|
|
193
|
-
raise TransportError, "HITL boundary deadline exceeded" unless IO.select(readable, writable, nil, remaining)
|
|
194
|
-
end
|
|
195
|
-
|
|
196
99
|
def connect!(socket, deadline)
|
|
197
100
|
if deadline && deadline <= Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
198
101
|
raise TransportError, "HITL boundary deadline exceeded"
|
|
@@ -203,49 +106,6 @@ module Ace
|
|
|
203
106
|
error = socket.getsockopt(Socket::SOL_SOCKET, Socket::SO_ERROR).int
|
|
204
107
|
raise SystemCallError.new("HITL boundary connection failed", error) unless error.zero?
|
|
205
108
|
end
|
|
206
|
-
|
|
207
|
-
def write_frame!(socket, frame, deadline)
|
|
208
|
-
offset = 0
|
|
209
|
-
while offset < frame.bytesize
|
|
210
|
-
await!(socket, :write, deadline)
|
|
211
|
-
written = socket.write_nonblock(frame.byteslice(offset..), exception: false)
|
|
212
|
-
next if written == :wait_writable
|
|
213
|
-
raise TransportError, "HITL boundary closed before request" unless written.is_a?(Integer) && written.positive?
|
|
214
|
-
offset += written
|
|
215
|
-
end
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
def deadline_for(op, params)
|
|
219
|
-
if op == "consume" && Integer(params["timeout"]) <= 0
|
|
220
|
-
nil
|
|
221
|
-
else
|
|
222
|
-
Process.clock_gettime(Process::CLOCK_MONOTONIC) + @deadline_seconds + Integer(params["timeout"])
|
|
223
|
-
end
|
|
224
|
-
rescue ArgumentError, TypeError
|
|
225
|
-
Process.clock_gettime(Process::CLOCK_MONOTONIC) + @deadline_seconds
|
|
226
|
-
end
|
|
227
|
-
|
|
228
|
-
def read_line(socket, deadline)
|
|
229
|
-
data = +""
|
|
230
|
-
loop do
|
|
231
|
-
remaining = deadline && deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
232
|
-
raise TransportError, "HITL boundary deadline exceeded" if remaining && remaining <= 0
|
|
233
|
-
ready = IO.select([socket], nil, nil, remaining)
|
|
234
|
-
raise TransportError, "HITL boundary deadline exceeded" unless ready
|
|
235
|
-
|
|
236
|
-
chunk = socket.read_nonblock(4096, exception: false)
|
|
237
|
-
raise TransportError, "HITL boundary closed before responding" if chunk.nil?
|
|
238
|
-
# A spurious not-ready result goes back to select; only the
|
|
239
|
-
# deadline ends the wait (review 8x32r9b4).
|
|
240
|
-
next if chunk == :wait_readable
|
|
241
|
-
|
|
242
|
-
data << chunk
|
|
243
|
-
if data.bytesize > Protocol::MAX_FRAME_BYTES
|
|
244
|
-
raise TransportError, "HITL boundary response is too large"
|
|
245
|
-
end
|
|
246
|
-
return data if data.include?("\n")
|
|
247
|
-
end
|
|
248
|
-
end
|
|
249
109
|
end
|
|
250
110
|
end
|
|
251
111
|
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Hitl
|
|
7
|
+
module Lifecycle
|
|
8
|
+
# Provisioning-time enforcement of the one exact ownership/mode
|
|
9
|
+
# layout of the host/Incus crossing (spec 8xa.t.nh0). There are no
|
|
10
|
+
# mode alternatives:
|
|
11
|
+
#
|
|
12
|
+
# - every credential-tree ancestor above the project leaf is
|
|
13
|
+
# `root:root` mode `0755` (verified up to the filesystem root);
|
|
14
|
+
# - the project credential directory is `root:<service-group>`
|
|
15
|
+
# mode `0750`;
|
|
16
|
+
# - the referenced private key is `<service-user>:<service-group>`
|
|
17
|
+
# mode `0400` and never hard-linked;
|
|
18
|
+
# - the referenced certificate and CA bundle are `root:root`
|
|
19
|
+
# mode `0444`;
|
|
20
|
+
# - the root-owned runtime file containing endpoint/credential
|
|
21
|
+
# references is `root:<service-group>` mode `0640`;
|
|
22
|
+
# - each path component and file is a regular owned object: no
|
|
23
|
+
# symlink, group/world-writable component, hard-linked key or
|
|
24
|
+
# ownership substitution is accepted.
|
|
25
|
+
#
|
|
26
|
+
# Exact mode + exact owner/group verification is the enforceable
|
|
27
|
+
# ACL proxy: any POSIX ACL that grants beyond the declared mode
|
|
28
|
+
# surfaces in the group class bits and fails the exact match.
|
|
29
|
+
#
|
|
30
|
+
# Any missing, unreadable, redirected, over-broad, incorrectly
|
|
31
|
+
# owned/mode-set or mismatched object REFUSES STARTUP before a
|
|
32
|
+
# socket is opened: every check runs before the TLS service binds.
|
|
33
|
+
class CredentialPathPolicy
|
|
34
|
+
Error = Class.new(Lifecycle::Error)
|
|
35
|
+
|
|
36
|
+
ANCESTOR_MODE = 0o755
|
|
37
|
+
PROJECT_DIR_MODE = 0o750
|
|
38
|
+
KEY_MODE = 0o400
|
|
39
|
+
PUBLIC_MODE = 0o444
|
|
40
|
+
RUNTIME_FILE_MODE = 0o640
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
# Verify the three referenced credential files of one project
|
|
44
|
+
# credential object (server or client form; both share the
|
|
45
|
+
# layout).
|
|
46
|
+
def verify_credential_tree!(credential:, service_user:, service_group:)
|
|
47
|
+
new(service_user: service_user, service_group: service_group)
|
|
48
|
+
.verify_credential_tree!(credential)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Verify one root-owned runtime configuration file that holds
|
|
52
|
+
# endpoint/credential references.
|
|
53
|
+
def verify_runtime_file!(path, service_group:)
|
|
54
|
+
new(service_user: nil, service_group: service_group).verify_runtime_file!(path)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def initialize(service_user:, service_group:)
|
|
59
|
+
# The runtime-file check verifies root ownership only; the
|
|
60
|
+
# service user is required for the key ownership rule.
|
|
61
|
+
@service_uid = service_user.nil? ? nil : resolve_user(service_user)
|
|
62
|
+
@service_gid = resolve_group(service_group)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def verify_credential_tree!(credential)
|
|
66
|
+
roles = {"certificate_file" => :public, "private_key_file" => :key, "ca_file" => :public}
|
|
67
|
+
# Pass one: structural verification of EVERY referenced file
|
|
68
|
+
# (existence, no symlink, regular file) before any ownership
|
|
69
|
+
# comparison — structural refusals never depend on privilege.
|
|
70
|
+
paths = roles.map do |field, role|
|
|
71
|
+
path = credential.fetch(field)
|
|
72
|
+
lstat_file!(path, "credential file")
|
|
73
|
+
[path, role]
|
|
74
|
+
end
|
|
75
|
+
# Pass two: the exact ownership/mode rules per role.
|
|
76
|
+
paths.each do |path, role|
|
|
77
|
+
verify_owner_mode!(path, role)
|
|
78
|
+
end
|
|
79
|
+
project_dir = File.dirname(paths.first.first)
|
|
80
|
+
unless paths.all? { |path, _role| File.dirname(path) == project_dir }
|
|
81
|
+
raise Error,
|
|
82
|
+
"credential files must share one project credential directory " \
|
|
83
|
+
"(#{paths.map(&:first).join(", ")})"
|
|
84
|
+
end
|
|
85
|
+
verify_project_directory(project_dir)
|
|
86
|
+
verify_ancestors(project_dir)
|
|
87
|
+
self
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def verify_runtime_file!(path)
|
|
91
|
+
stat = lstat_file!(path, "runtime configuration file")
|
|
92
|
+
unless stat.uid.zero? && stat.gid == @service_gid && (stat.mode & 0o7777) == RUNTIME_FILE_MODE
|
|
93
|
+
raise Error,
|
|
94
|
+
"runtime configuration file must be root:<service-group> mode 0640: #{path} " \
|
|
95
|
+
"(got uid=#{stat.uid} gid=#{stat.gid} mode=#{stat.mode.to_s(8)})"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
self
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def verify_owner_mode!(path, role)
|
|
104
|
+
stat = File.lstat(path)
|
|
105
|
+
expected_owner = role == :key ? [@service_uid, @service_gid] : [0, 0]
|
|
106
|
+
expected_mode = role == :key ? KEY_MODE : PUBLIC_MODE
|
|
107
|
+
unless [stat.uid, stat.gid] == expected_owner && (stat.mode & 0o7777) == expected_mode
|
|
108
|
+
raise Error,
|
|
109
|
+
"credential file has the wrong owner or mode: #{path} (got uid=#{stat.uid} " \
|
|
110
|
+
"gid=#{stat.gid} mode=#{stat.mode.to_s(8)})"
|
|
111
|
+
end
|
|
112
|
+
if role == :key && stat.nlink != 1
|
|
113
|
+
raise Error, "credential private key must not be hard-linked: #{path}"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def verify_project_directory(path)
|
|
118
|
+
stat = File.lstat(path)
|
|
119
|
+
unless stat.directory? && !stat.symlink?
|
|
120
|
+
raise Error, "project credential directory is not a regular directory: #{path}"
|
|
121
|
+
end
|
|
122
|
+
unless stat.uid.zero? && stat.gid == @service_gid && (stat.mode & 0o7777) == PROJECT_DIR_MODE
|
|
123
|
+
raise Error,
|
|
124
|
+
"project credential directory must be root:<service-group> mode 0750: #{path} " \
|
|
125
|
+
"(got uid=#{stat.uid} gid=#{stat.gid} mode=#{stat.mode.to_s(8)})"
|
|
126
|
+
end
|
|
127
|
+
rescue Errno::ENOENT
|
|
128
|
+
raise Error, "project credential directory does not exist: #{path}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Every ancestor above the project leaf — up to the filesystem
|
|
132
|
+
# root — is a root:root 0755 directory. A group/world-writable,
|
|
133
|
+
# foreign-owned or symlinked component anywhere on the chain
|
|
134
|
+
# would allow credential substitution.
|
|
135
|
+
def verify_ancestors(path)
|
|
136
|
+
parent = File.expand_path("..", path)
|
|
137
|
+
components = parent.split("/").reject(&:empty?)
|
|
138
|
+
verify_ancestor!("/", File.lstat("/"))
|
|
139
|
+
components.each_with_object("") do |component, current|
|
|
140
|
+
current << "/#{component}"
|
|
141
|
+
stat = File.lstat(current)
|
|
142
|
+
verify_ancestor!(current, stat)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def verify_ancestor!(path, stat)
|
|
147
|
+
unless stat.directory? && !stat.symlink? && stat.uid.zero? && stat.gid.zero? &&
|
|
148
|
+
(stat.mode & 0o7777) == ANCESTOR_MODE
|
|
149
|
+
raise Error,
|
|
150
|
+
"credential path ancestor must be a root:root mode 0755 directory: #{path} " \
|
|
151
|
+
"(got uid=#{stat.uid} gid=#{stat.gid} mode=#{stat.mode.to_s(8)})"
|
|
152
|
+
end
|
|
153
|
+
rescue Errno::ENOENT
|
|
154
|
+
raise Error, "credential path ancestor does not exist: #{path}"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def lstat_file!(path, label)
|
|
158
|
+
stat = File.lstat(path)
|
|
159
|
+
raise Error, "#{label} must not be a symlink: #{path}" if stat.symlink?
|
|
160
|
+
raise Error, "#{label} is not a regular file: #{path}" unless stat.file?
|
|
161
|
+
|
|
162
|
+
stat
|
|
163
|
+
rescue Errno::ENOENT
|
|
164
|
+
raise Error, "#{label} does not exist: #{path}"
|
|
165
|
+
rescue SystemCallError
|
|
166
|
+
raise Error, "#{label} is not verifiable: #{path}"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def resolve_user(name)
|
|
170
|
+
Etc.getpwnam(name.to_s).uid
|
|
171
|
+
rescue ArgumentError
|
|
172
|
+
raise Error, "the configured service user does not exist: #{name.inspect}"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def resolve_group(name)
|
|
176
|
+
Etc.getgrnam(name.to_s).gid
|
|
177
|
+
rescue ArgumentError
|
|
178
|
+
raise Error, "the configured service group does not exist: #{name.inspect}"
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|