ace-hitl 0.15.0 → 0.17.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 +24 -0
- data/README.md +48 -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 +12 -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/operator_question.rb +27 -0
- data/lib/ace/hitl/cli/commands/read.rb +22 -0
- data/lib/ace/hitl/cli/commands/serve.rb +12 -1
- data/lib/ace/hitl/cli/commands/show.rb +0 -0
- data/lib/ace/hitl/cli.rb +6 -0
- data/lib/ace/hitl/lifecycle/binding.rb +13 -0
- data/lib/ace/hitl/lifecycle/frame_serving.rb +2 -1
- data/lib/ace/hitl/lifecycle/lifecycle_requests.rb +4 -0
- data/lib/ace/hitl/lifecycle/operator_questions.rb +84 -0
- data/lib/ace/hitl/lifecycle/policy.rb +49 -3
- data/lib/ace/hitl/lifecycle/proposals.rb +12 -0
- data/lib/ace/hitl/lifecycle/protocol.rb +1 -1
- data/lib/ace/hitl/lifecycle/service.rb +5 -3
- data/lib/ace/hitl/lifecycle/store.rb +66 -7
- data/lib/ace/hitl/lifecycle/tls_access_policy.rb +16 -1
- data/lib/ace/hitl/lifecycle/tls_authority.rb +14 -5
- data/lib/ace/hitl/lifecycle/tls_service.rb +4 -3
- 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 +2 -0
- data/lib/ace/hitl/providers/lab/assignment_binding.rb +20 -1
- data/lib/ace/hitl/version.rb +1 -1
- metadata +25 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8ab176a502d7a751a22e89f461245046caa44d71943897b7f27b6d5ffdd10c84
|
|
4
|
+
data.tar.gz: f789375f072b07f087462b78fd2d2fb7a9ae45e167eedbd413a11fe09e9ead0e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b6f1c00953d56deac3fe8e7099a1aac527681b1314aeda336c7691de04bf7ab40af33c8815ddcac76777e02151609c555cb0f4f0be2276e8a7b8ab0359b3a9ae
|
|
7
|
+
data.tar.gz: 19e978992b0041ae887a26d743555fe69b8292fd01c763c488bef96e6feecb00c3437f58909d015eeaefa21884b552b203af47f94e583b2feef4e8bc3f4e06fc
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.17.0] - 2026-10-16
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Add explicit unassigned operator questions with kernel requester UID, exact installed project grants, stable retries and existing request/terminal recovery. Expose public client/CLI creation/read and an operator-only service binding that refuses managed execution, secrets and effects.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- Require the ace-hitl-contract 0.3 operator-question envelope for the tagged request union.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Require a reconciliation checkpoint to name the canonical proposal project before silence can authorize its effect.
|
|
23
|
+
|
|
24
|
+
## [0.16.0] - 2026-10-16
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- 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.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- 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.
|
|
33
|
+
|
|
10
34
|
## [0.15.0] - 2026-10-14
|
|
11
35
|
|
|
12
36
|
### Added
|
data/README.md
CHANGED
|
@@ -87,3 +87,51 @@ Verification commands:
|
|
|
87
87
|
`ace-hitl` owns HITL-specific event semantics and markdown contract.
|
|
88
88
|
|
|
89
89
|
`ace-support-items` remains generic support infrastructure and should not absorb HITL-specific domain behavior.
|
|
90
|
+
|
|
91
|
+
## Unassigned operator questions
|
|
92
|
+
|
|
93
|
+
`ace-hitl operator-question ID --project lab-config --question "Which option?"`
|
|
94
|
+
creates a nonsecret question under an explicit installed operator grant. An
|
|
95
|
+
optional `--plan` adds question context; `--options A,B` supplies bounded choices.
|
|
96
|
+
The owner binds the exact Captain-visible body including this context and choices. It does not create an Assign execution,
|
|
97
|
+
request a command, grant a decision, or permit OTP/effects. Stable IDs support
|
|
98
|
+
exact retries after lost responses, restart and consumption; changed content,
|
|
99
|
+
principal, project or context is refused. `ace-hitl read ID` returns authenticated
|
|
100
|
+
nonsecret facts; existing `consume ID` retrieves the requester's answer and
|
|
101
|
+
replays its terminal receipt.
|
|
102
|
+
|
|
103
|
+
The public Ruby client exposes `operator_question(id:, project:, question:,
|
|
104
|
+
plan: "", options: [])`, `read`, and the existing pending/deliver/consume/cancel
|
|
105
|
+
methods. The request records the actual kernel peer UID/name. No caller can supply
|
|
106
|
+
requester attribution, assignment, attempt, OTP, effect or publication fields.
|
|
107
|
+
Secret-shaped questions, context, choices and answers are refused.
|
|
108
|
+
|
|
109
|
+
An operator-only service uses the existing owner and Unix protocol:
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
ace-hitl serve --operator-only --group ace-hitl-project \
|
|
113
|
+
--socket /run/ace-hitl-project/lifecycle.sock --store-root /var/lib/ace-hitl-project \
|
|
114
|
+
--grants-path /etc/ace-hitl-project/authorization.yml
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The root-controlled grants document must explicitly name `hitl.service_uid`,
|
|
118
|
+
`hitl.operator_uids`, `hitl.transport_uids` and each UID's exact project list in
|
|
119
|
+
`authorization.principals`. Operator grants never interpret an empty project list
|
|
120
|
+
as all projects. Existing trusted-file ownership/traversal checks apply. The
|
|
121
|
+
operator-only binding refuses managed requests and requires no Assign journal.
|
|
122
|
+
Admission and requester grants are checked again on reads, answer transitions,
|
|
123
|
+
retries and terminal replay; transport delivery retains its independent project
|
|
124
|
+
grant. Revocation preserves request and terminal history. Operator questions
|
|
125
|
+
cannot be consumed as native execution or authorized-operation claims.
|
|
126
|
+
|
|
127
|
+
`ping` advertises the supported `request_contexts` (`managed` and/or
|
|
128
|
+
`operator-question`), not a grant to create or execute work. Hermes project
|
|
129
|
+
routes may require `request_contexts: ["operator-question"]` at startup. The
|
|
130
|
+
shared contract uses a closed tagged union of managed execution requests and
|
|
131
|
+
operator questions; neither missing IDs nor an unknown schema select a fallback.
|
|
132
|
+
|
|
133
|
+
One Unix UID with both roles is one OS trust domain. Use a distinct transport
|
|
134
|
+
principal where Captain reply isolation is required. Source tests using one UID
|
|
135
|
+
for both roles do not prove that isolation. Domain adapters must separately
|
|
136
|
+
preserve legacy unresolved host questions and prove the installed group handoff;
|
|
137
|
+
this API never migrates or deletes that legacy history.
|
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:
|
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
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "ace/support/cli"
|
|
3
|
+
require_relative "lifecycle_command"
|
|
4
|
+
module Ace
|
|
5
|
+
module Hitl
|
|
6
|
+
module CLI
|
|
7
|
+
module Commands
|
|
8
|
+
class OperatorQuestion < Ace::Support::Cli::Command
|
|
9
|
+
include Ace::Support::Cli::Base
|
|
10
|
+
include LifecycleCommand
|
|
11
|
+
desc "Create a nonsecret operator question without an execution or effect grant"
|
|
12
|
+
argument :id, required: true, desc: "Stable question ID; reuse for an exact retry"
|
|
13
|
+
option :project, type: :string, required: true, desc: "Exact installed operator project scope"
|
|
14
|
+
option :question, type: :string, required: true, desc: "Nonsecret question"
|
|
15
|
+
option :plan, type: :string, desc: "Nonsecret question context"
|
|
16
|
+
option :options, type: :array, desc: "Nonsecret choices (comma-separated)"
|
|
17
|
+
def call(id:, **options)
|
|
18
|
+
emit(lifecycle_client.operator_question(id: id, project: options.fetch(:project),
|
|
19
|
+
question: options.fetch(:question), plan: options.fetch(:plan, ""), options: options.fetch(:options, [])))
|
|
20
|
+
rescue Lifecycle::Error, Providers::ProviderUnavailableError => e
|
|
21
|
+
raise_lifecycle_error(e.message)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "ace/support/cli"
|
|
3
|
+
require_relative "lifecycle_command"
|
|
4
|
+
module Ace
|
|
5
|
+
module Hitl
|
|
6
|
+
module CLI
|
|
7
|
+
module Commands
|
|
8
|
+
class Read < Ace::Support::Cli::Command
|
|
9
|
+
include Ace::Support::Cli::Base
|
|
10
|
+
include LifecycleCommand
|
|
11
|
+
desc "Read authenticated nonsecret lifecycle facts for one request"
|
|
12
|
+
argument :id, required: true, desc: "Request ID"
|
|
13
|
+
def call(id:, **)
|
|
14
|
+
emit(lifecycle_client.read(id))
|
|
15
|
+
rescue Lifecycle::Error, Providers::ProviderUnavailableError => e
|
|
16
|
+
raise_lifecycle_error(e.message)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -23,6 +23,8 @@ module Ace
|
|
|
23
23
|
desc: "Dedicated lifecycle group allowed to connect (required; root and lab-control are refused)"
|
|
24
24
|
option :store_root, type: :string, desc: "Private store root (default: ACE_HITL_STORE_ROOT or /run/lab/hitl)"
|
|
25
25
|
option :grants_path, type: :string, desc: "Trusted grants document (default: ACE_HITL_GRANTS_PATH or /etc/lab/ace-lab/authorization.yml)"
|
|
26
|
+
option :operator_only, type: :boolean,
|
|
27
|
+
desc: "Serve explicitly granted nonsecret operator questions without a managed execution authority"
|
|
26
28
|
option :repo_root, type: :string, desc: "Candidate repository for the managed attempt authority"
|
|
27
29
|
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)"
|
|
28
30
|
|
|
@@ -30,6 +32,9 @@ module Ace
|
|
|
30
32
|
option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
|
|
31
33
|
|
|
32
34
|
def call(**options)
|
|
35
|
+
if options[:tls_authority] && options[:operator_only]
|
|
36
|
+
raise_cli_error("operator questions require a local kernel requester; operator-only TLS is not supported")
|
|
37
|
+
end
|
|
33
38
|
return serve_tls_authority(options) if options[:tls_authority]
|
|
34
39
|
|
|
35
40
|
group = dedicated_group(options)
|
|
@@ -41,7 +46,12 @@ module Ace
|
|
|
41
46
|
"fail every client endpoint verification"
|
|
42
47
|
)
|
|
43
48
|
end
|
|
44
|
-
binding_policy =
|
|
49
|
+
binding_policy = if options[:operator_only]
|
|
50
|
+
raise_cli_error("operator-only serve cannot select a managed repository") if options[:repo_root]
|
|
51
|
+
Lifecycle::OperatorOnlyBinding.new
|
|
52
|
+
else
|
|
53
|
+
Providers::Lab.assignment_binding(repo_root: options[:repo_root])
|
|
54
|
+
end
|
|
45
55
|
service = Lifecycle::Service.new(
|
|
46
56
|
root: store_root(options),
|
|
47
57
|
binding: binding_policy,
|
|
@@ -69,6 +79,7 @@ module Ace
|
|
|
69
79
|
binding: binding_policy,
|
|
70
80
|
project: authority.project_id,
|
|
71
81
|
credential: authority.credential,
|
|
82
|
+
decision_policy: Providers::Lab.grants_policy(grants_path: grants_path(options)),
|
|
72
83
|
ssl_context: authority.ssl_context!,
|
|
73
84
|
listen: authority.listen,
|
|
74
85
|
port: authority.port,
|
|
File without changes
|
data/lib/ace/hitl/cli.rb
CHANGED
|
@@ -4,6 +4,8 @@ require "ace/support/cli"
|
|
|
4
4
|
require_relative "../hitl/version"
|
|
5
5
|
require_relative "cli/commands/create"
|
|
6
6
|
require_relative "cli/commands/ask"
|
|
7
|
+
require_relative "cli/commands/operator_question"
|
|
8
|
+
require_relative "cli/commands/read"
|
|
7
9
|
require_relative "cli/commands/show"
|
|
8
10
|
require_relative "cli/commands/list"
|
|
9
11
|
require_relative "cli/commands/update"
|
|
@@ -30,6 +32,8 @@ module Ace
|
|
|
30
32
|
REGISTERED_COMMANDS = [
|
|
31
33
|
["proposal", "Create, inspect, revise or resolve an immutable second-commander proposal"],
|
|
32
34
|
["create", "Create HITL event"],
|
|
35
|
+
["operator-question", "Create a nonsecret question under an installed operator project grant"],
|
|
36
|
+
["read", "Read authenticated lifecycle request facts"],
|
|
33
37
|
["ask", "Ask a human via HITL and forward the request to the Lab"],
|
|
34
38
|
["show", "Show HITL event details"],
|
|
35
39
|
["list", "List HITL events"],
|
|
@@ -58,6 +62,8 @@ module Ace
|
|
|
58
62
|
register "create", CLI::Commands::Create
|
|
59
63
|
register "proposal", CLI::Commands::Proposal
|
|
60
64
|
register "ask", CLI::Commands::Ask
|
|
65
|
+
register "operator-question", CLI::Commands::OperatorQuestion
|
|
66
|
+
register "read", CLI::Commands::Read
|
|
61
67
|
register "show", CLI::Commands::Show
|
|
62
68
|
register "list", CLI::Commands::List
|
|
63
69
|
register "update", CLI::Commands::Update
|
|
@@ -31,6 +31,19 @@ module Ace
|
|
|
31
31
|
raise NotImplementedError
|
|
32
32
|
end
|
|
33
33
|
end
|
|
34
|
+
# Explicit operator-only authority. Managed requests remain fail closed
|
|
35
|
+
# without creating or consulting a pretend Assign execution.
|
|
36
|
+
class OperatorOnlyBinding < Binding
|
|
37
|
+
def validate_request(**)
|
|
38
|
+
raise BindingError, "operator-only authority does not admit managed requests"
|
|
39
|
+
end
|
|
40
|
+
def reverse_address(**)
|
|
41
|
+
raise BindingError, "operator questions have no native execution address"
|
|
42
|
+
end
|
|
43
|
+
def with_active(**)
|
|
44
|
+
raise BindingError, "operator-only authority does not admit managed requests"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
34
47
|
end
|
|
35
48
|
end
|
|
36
49
|
end
|
|
@@ -42,7 +42,7 @@ module Ace
|
|
|
42
42
|
|
|
43
43
|
def execute(store, op, params)
|
|
44
44
|
case op
|
|
45
|
-
when "ping" then
|
|
45
|
+
when "ping" then store.capabilities
|
|
46
46
|
when "proposal-create" then store.proposal_create(**symbolize(params))
|
|
47
47
|
when "proposal-show" then store.proposal_show(required(params, "id"), project: required(params, "project"), history_after: params.fetch("history_after", 0))
|
|
48
48
|
when "proposal-revise" then store.proposal_revise(required(params, "id"), project: required(params, "project"), expected_revision: required(params, "expected_revision"),
|
|
@@ -58,6 +58,7 @@ module Ace
|
|
|
58
58
|
when "proposal-due" then store.proposal_due(project: required(params, "project"), after: params["after"])
|
|
59
59
|
when "proposal-history" then store.proposal_history(project: required(params, "project"),
|
|
60
60
|
query: params.fetch("query", ""), after: params["after"])
|
|
61
|
+
when "operator-question" then store.operator_question(**symbolize(params))
|
|
61
62
|
when "create" then store.create(**symbolize(params))
|
|
62
63
|
when "read" then store.read(required(params, "id"))
|
|
63
64
|
when "deliver" then store.deliver(required(params, "id"), answer_reader(params))
|
|
@@ -9,6 +9,10 @@ module Ace
|
|
|
9
9
|
# lifecycle frames are exchanged unchanged on both transports.
|
|
10
10
|
# The includer implements `#request(op, params, deadline:)`.
|
|
11
11
|
module LifecycleRequests
|
|
12
|
+
def operator_question(**params)
|
|
13
|
+
request("operator-question", params.transform_keys(&:to_s))
|
|
14
|
+
end
|
|
15
|
+
|
|
12
16
|
def proposal_create(**params)
|
|
13
17
|
request("proposal-create", params)
|
|
14
18
|
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "ace/hitl/contract/request_envelope"
|
|
3
|
+
|
|
4
|
+
module Ace
|
|
5
|
+
module Hitl
|
|
6
|
+
module Lifecycle
|
|
7
|
+
module OperatorQuestions
|
|
8
|
+
def operator_question(id:, project:, question:, plan: "", options: [])
|
|
9
|
+
unless @identity.uid.is_a?(Integer) && @policy.operator_question?(uid: @identity.uid, project: project)
|
|
10
|
+
raise PermissionError, "operator question requires an installed UID and exact project grant"
|
|
11
|
+
end
|
|
12
|
+
unless project.is_a?(String) && Contract::ManagedEnvelope::TOKEN.match?(project) &&
|
|
13
|
+
question.is_a?(String) && question.valid_encoding? && !question.strip.empty? &&
|
|
14
|
+
question.length <= Store::MAX_PLAN_QUESTION && !question.include?("\0") &&
|
|
15
|
+
plan.is_a?(String) && plan.valid_encoding? && plan.length <= Store::MAX_PLAN_QUESTION &&
|
|
16
|
+
!plan.include?("\0") && options.is_a?(Array) && options.length <= Store::MAX_OPTIONS &&
|
|
17
|
+
options.all? { |item| item.is_a?(String) && item.valid_encoding? && !item.strip.empty? &&
|
|
18
|
+
item.length <= Store::MAX_OPTION && !item.include?("\0") }
|
|
19
|
+
raise StateError, "invalid nonsecret operator question content"
|
|
20
|
+
end
|
|
21
|
+
[question, plan, *options].each { |text| Kinds.check_answer!("question", text) }
|
|
22
|
+
question, plan = question.strip, plan.strip
|
|
23
|
+
options = options.map(&:strip)
|
|
24
|
+
input_digest = Digest::SHA256.hexdigest(JSON.generate([plan, question, options]))
|
|
25
|
+
# The authority binds the actual Captain-visible question, including
|
|
26
|
+
# its optional context/choices. Hermes publishes these exact bytes.
|
|
27
|
+
question += "\n\nContext: #{plan}" unless plan.empty?
|
|
28
|
+
unless options.empty?
|
|
29
|
+
question += "\n\nOptions:\n" + options.each_with_index.map { |item, index| "#{index + 1}. #{item}" }.join("\n")
|
|
30
|
+
end
|
|
31
|
+
request_id = safe_id(id)
|
|
32
|
+
incarnation = SecureRandom.hex(8)
|
|
33
|
+
envelope = Contract::OperatorEnvelope.load({
|
|
34
|
+
"schema" => Contract::OperatorEnvelope::SCHEMA, "context" => "operator-question",
|
|
35
|
+
"request_id" => request_id, "request_incarnation" => incarnation, "project" => project,
|
|
36
|
+
"requester" => @identity.username, "requester_uid" => @identity.uid,
|
|
37
|
+
"correlation_id" => request_id, "kind" => "question",
|
|
38
|
+
"payload_sha256" => Digest::SHA256.hexdigest(question),
|
|
39
|
+
"input_sha256" => input_digest})
|
|
40
|
+
value = {"id" => request_id, "project" => project, "requester" => @identity.username,
|
|
41
|
+
"requester_uid" => @identity.uid, "harness" => "operator", "kind" => "question", "sensitive" => false,
|
|
42
|
+
"plan" => plan, "question" => question, "options" => options, "created_at" => Time.now.to_i,
|
|
43
|
+
"incarnation" => incarnation, "envelope" => envelope}
|
|
44
|
+
with_request_lock(request_id) do
|
|
45
|
+
operator_authority!(value)
|
|
46
|
+
terminal = load_terminal(request_id)
|
|
47
|
+
previous = terminal
|
|
48
|
+
previous ||= load_request(request_id) if request_path(request_id).exist?
|
|
49
|
+
if previous
|
|
50
|
+
requester_gate!(previous)
|
|
51
|
+
old = Contract::RequestEnvelope.from_facts(previous)
|
|
52
|
+
unless old["schema"] == Contract::OperatorEnvelope::SCHEMA &&
|
|
53
|
+
old.except("request_incarnation") == envelope.except("request_incarnation")
|
|
54
|
+
raise StateError, "operator question retry changed immutable content or context"
|
|
55
|
+
end
|
|
56
|
+
initialize_projection_locked!(previous) unless terminal
|
|
57
|
+
return {"id" => request_id, "requested" => true, "replay" => true, "envelope" => old}
|
|
58
|
+
end
|
|
59
|
+
persist_request_locked!(value)
|
|
60
|
+
end
|
|
61
|
+
rescue Contract::InvalidEnvelope => e
|
|
62
|
+
raise StateError, e.message
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def operator_request?(value)
|
|
68
|
+
value.dig("envelope", "schema") == Contract::OperatorEnvelope::SCHEMA
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def operator_authority!(value)
|
|
72
|
+
envelope = Contract::RequestEnvelope.from_facts(value)
|
|
73
|
+
unless envelope["schema"] == Contract::OperatorEnvelope::SCHEMA &&
|
|
74
|
+
@policy.operator_question?(uid: envelope.fetch("requester_uid"), project: envelope.fetch("project"))
|
|
75
|
+
raise PermissionError, "operator requester project grant is unavailable or revoked"
|
|
76
|
+
end
|
|
77
|
+
envelope
|
|
78
|
+
rescue Contract::InvalidEnvelope => e
|
|
79
|
+
raise StateError, e.message
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -102,9 +102,21 @@ module Ace
|
|
|
102
102
|
# the contract. The default denies everything — direct-library use
|
|
103
103
|
# gets requester-only semantics and no transport authority.
|
|
104
104
|
class AccessPolicy
|
|
105
|
+
def operator_question?(uid:, project:)
|
|
106
|
+
false
|
|
107
|
+
end
|
|
108
|
+
|
|
105
109
|
def proposal?(_peer, project:)
|
|
106
110
|
false
|
|
107
111
|
end
|
|
112
|
+
|
|
113
|
+
def proposal_silence?(caller_uid:, requester:, project:, operation:)
|
|
114
|
+
false
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def silence_operation?(project:, operation:)
|
|
118
|
+
false
|
|
119
|
+
end
|
|
108
120
|
# @return [Boolean] true when the peer may deliver answers and
|
|
109
121
|
# observe pending/states for the given project
|
|
110
122
|
def transport?(_peer, project: nil)
|
|
@@ -129,6 +141,17 @@ module Ace
|
|
|
129
141
|
@document = document
|
|
130
142
|
end
|
|
131
143
|
|
|
144
|
+
# Operator questions have exact installed project scope, never the
|
|
145
|
+
# historical empty-list transport wildcard. UID is taken from the kernel
|
|
146
|
+
# peer on creation and retained in the protected request on transitions.
|
|
147
|
+
def operator_question?(uid:, project:)
|
|
148
|
+
current = document
|
|
149
|
+
uids = hitl_facts(current)["operator_uids"]
|
|
150
|
+
projects = authorized_projects(uid, current)
|
|
151
|
+
uid.is_a?(Integer) && uids.is_a?(Array) && uids.include?(uid) &&
|
|
152
|
+
projects.is_a?(Array) && projects.include?(project)
|
|
153
|
+
end
|
|
154
|
+
|
|
132
155
|
def transport?(peer, project: nil)
|
|
133
156
|
current = document
|
|
134
157
|
facts = hitl_facts(current)
|
|
@@ -151,13 +174,36 @@ module Ace
|
|
|
151
174
|
# an ordinary requester or transport grant.
|
|
152
175
|
def proposal?(peer, project:)
|
|
153
176
|
current = document
|
|
177
|
+
proposal_principal?(peer.uid, project, current)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# A proposer grant allows asking. Only this separate, exact project
|
|
181
|
+
# operation list delegates a decision after the acknowledged window.
|
|
182
|
+
def proposal_silence?(caller_uid:, requester:, project:, operation:)
|
|
183
|
+
current = document
|
|
184
|
+
proposal_principal?(caller_uid, project, current) &&
|
|
185
|
+
delegated_operation?(project, operation, current)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def silence_operation?(project:, operation:)
|
|
189
|
+
delegated_operation?(project, operation, document)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
private
|
|
193
|
+
|
|
194
|
+
def proposal_principal?(uid, project, current)
|
|
154
195
|
uids = hitl_facts(current)["proposal_uids"]
|
|
155
|
-
projects = authorized_projects(
|
|
156
|
-
uids.is_a?(Array) && uids.include?(
|
|
196
|
+
projects = authorized_projects(uid, current)
|
|
197
|
+
uid.is_a?(Integer) && uids.is_a?(Array) && uids.include?(uid) && projects &&
|
|
157
198
|
(projects.empty? || projects.include?(project.to_s))
|
|
158
199
|
end
|
|
159
200
|
|
|
160
|
-
|
|
201
|
+
def delegated_operation?(project, operation, current)
|
|
202
|
+
delegations = hitl_facts(current)["silence_operations"]
|
|
203
|
+
operations = delegations.is_a?(Hash) && delegations[project]
|
|
204
|
+
operations.is_a?(Array) && operation.is_a?(String) &&
|
|
205
|
+
operation.match?(/\A[a-z][a-z0-9-]{0,63}\z/) && operations.include?(operation)
|
|
206
|
+
end
|
|
161
207
|
|
|
162
208
|
def document
|
|
163
209
|
@document || TrustedFile.read_yaml(@grants_path) || {}
|
|
@@ -54,6 +54,7 @@ module Ace
|
|
|
54
54
|
claims = proposal_journal(project).proposal_claims(record["revision_id"])
|
|
55
55
|
claim = claims.last
|
|
56
56
|
outcome = case claim && claim["state"]
|
|
57
|
+
when "pending" then "queued"
|
|
57
58
|
when "accepted" then "executing"
|
|
58
59
|
when "succeeded" then "succeeded"
|
|
59
60
|
when "failed", "failed-settled", "rejected" then "failed"
|
|
@@ -88,6 +89,7 @@ module Ace
|
|
|
88
89
|
record = proposal_for_request!(request, project: project)
|
|
89
90
|
require_transport!("proposal reconciliation", {"project" => record["project_id"]})
|
|
90
91
|
proposal_transition(record) do |current|
|
|
92
|
+
next current unless silence_delegated?(current)
|
|
91
93
|
Ace::Hitl::Proposals::Policy.resolve(current, checkpoint: checkpoint, now: proposal_now)
|
|
92
94
|
end
|
|
93
95
|
end
|
|
@@ -136,6 +138,11 @@ module Ace
|
|
|
136
138
|
|
|
137
139
|
private
|
|
138
140
|
|
|
141
|
+
def silence_delegated?(record)
|
|
142
|
+
@policy.proposal_silence?(caller_uid: record["caller_uid"], requester: record["requester"],
|
|
143
|
+
project: record["project_id"], operation: record["operation"])
|
|
144
|
+
end
|
|
145
|
+
|
|
139
146
|
def require_proposer!(project)
|
|
140
147
|
unless @policy.respond_to?(:proposal?) && @policy.proposal?(@identity, project: project)
|
|
141
148
|
raise PermissionError, "proposal requires the installed second-commander decision principal"
|
|
@@ -189,6 +196,8 @@ module Ace
|
|
|
189
196
|
def proposal_transition(record, &policy)
|
|
190
197
|
proposal_journal(record.fetch("project_id")).change_proposal(record["proposal_id"], assignment_id: record["assignment_id"],
|
|
191
198
|
attempt_id: record["attempt_id"]) do |current|
|
|
199
|
+
# Selection may change while waiting for the canonical ref lock.
|
|
200
|
+
proposal_journal(record.fetch("project_id"))
|
|
192
201
|
# A reply to an older revision cannot modify the new decision.
|
|
193
202
|
next current unless current["request_id"] == record["request_id"]
|
|
194
203
|
policy.call(current)
|
|
@@ -203,6 +212,7 @@ module Ace
|
|
|
203
212
|
replay = nil
|
|
204
213
|
record = @binding.with_active(assignment: assignment, attempt: attempt, project: project, requester: @identity.username) do
|
|
205
214
|
proposal_journal(project).change_proposal(id, assignment_id: assignment, attempt_id: attempt) do |previous|
|
|
215
|
+
proposal_journal(project)
|
|
206
216
|
if operation_id
|
|
207
217
|
replay = proposal_revision_replay(id, expected_revision, operation_id, content, project: project)
|
|
208
218
|
next previous if replay
|
|
@@ -228,6 +238,8 @@ module Ace
|
|
|
228
238
|
"attempt_id" => attempt, "project_id" => project, "requester" => @identity.username,
|
|
229
239
|
"caller_uid" => @identity.uid, "content_digest" => Digest::SHA256.hexdigest(JSON.generate(content)),
|
|
230
240
|
"state" => "awaiting-delivery", "created_at" => proposal_now,
|
|
241
|
+
"silence_delegated" => @policy.proposal_silence?(caller_uid: @identity.uid,
|
|
242
|
+
requester: @identity.username, project: project, operation: content.fetch("operation")),
|
|
231
243
|
"authorization" => request, "lifecycle_request" => projection).tap do |value|
|
|
232
244
|
if operation_id
|
|
233
245
|
value["revision_operation_id"] = operation_id
|
|
@@ -14,7 +14,7 @@ module Ace
|
|
|
14
14
|
VERSION = 1
|
|
15
15
|
MAX_FRAME_BYTES = 64 * 1024
|
|
16
16
|
DEFAULT_DEADLINE_SECONDS = 10.0
|
|
17
|
-
OPERATIONS = %w[create read deliver consume cancel pending states ping
|
|
17
|
+
OPERATIONS = %w[operator-question create read deliver consume cancel pending states ping
|
|
18
18
|
proposal-create proposal-show proposal-revise proposal-ack proposal-reply proposal-reconcile proposal-due proposal-history proposal-wake].freeze
|
|
19
19
|
|
|
20
20
|
# Wire failures map to the transport error; classified lifecycle
|
|
@@ -186,10 +186,12 @@ module Ace
|
|
|
186
186
|
# the service identity or root — otherwise anyone who can replace
|
|
187
187
|
# the socket can impersonate the service.
|
|
188
188
|
def verify_control_group!
|
|
189
|
-
|
|
190
|
-
|
|
189
|
+
# Named broad groups are forbidden even on a host where that
|
|
190
|
+
# deployment group has not been created yet.
|
|
191
|
+
group = Etc.getgrnam(@group) unless BROAD_CONTROL_GROUPS.include?(@group)
|
|
192
|
+
if group.nil? || group.gid.zero? || BROAD_CONTROL_GROUPS.include?(group.name)
|
|
191
193
|
raise TransportError,
|
|
192
|
-
"HITL boundary control group is too broad: #{group
|
|
194
|
+
"HITL boundary control group is too broad: #{@group} " \
|
|
193
195
|
"(configure a dedicated lifecycle group; root and #{Store::DEFAULT_GROUP} are refused)"
|
|
194
196
|
end
|
|
195
197
|
|
|
@@ -11,6 +11,7 @@ require_relative "policy"
|
|
|
11
11
|
require_relative "atomic_json"
|
|
12
12
|
require_relative "kinds"
|
|
13
13
|
require_relative "binding"
|
|
14
|
+
require_relative "operator_questions"
|
|
14
15
|
require_relative "effects"
|
|
15
16
|
require_relative "otp_vault"
|
|
16
17
|
require_relative "protocol"
|
|
@@ -37,6 +38,7 @@ module Ace
|
|
|
37
38
|
# with_active scope (the assignment exclusion), so stale, ended
|
|
38
39
|
# or replaced attempts cannot acquire new authority.
|
|
39
40
|
class Store
|
|
41
|
+
include OperatorQuestions
|
|
40
42
|
include Proposals
|
|
41
43
|
MAX_ANSWER = 4096
|
|
42
44
|
# IO#read(limit) is byte-oriented, so the character limit is
|
|
@@ -97,6 +99,12 @@ module Ace
|
|
|
97
99
|
@group = group
|
|
98
100
|
end
|
|
99
101
|
|
|
102
|
+
def capabilities
|
|
103
|
+
contexts = ["operator-question"]
|
|
104
|
+
contexts.unshift("managed") unless @binding.is_a?(OperatorOnlyBinding)
|
|
105
|
+
{"pong" => true, "request_contexts" => contexts}
|
|
106
|
+
end
|
|
107
|
+
|
|
100
108
|
# ---- requester side ------------------------------------------------
|
|
101
109
|
|
|
102
110
|
# Create one request bound to the exact live attempt of the
|
|
@@ -221,7 +229,11 @@ module Ace
|
|
|
221
229
|
|
|
222
230
|
def persist_request_locked!(value)
|
|
223
231
|
request_id = value.fetch("id")
|
|
224
|
-
attempt = value
|
|
232
|
+
attempt = value["attempt"]
|
|
233
|
+
terminal = load_terminal(request_id)
|
|
234
|
+
if terminal && operator_request?(terminal)
|
|
235
|
+
raise StateError, "operator question identity is terminal and cannot acquire another context"
|
|
236
|
+
end
|
|
225
237
|
request_path = requests_dir.join("#{request_id}.json")
|
|
226
238
|
raise StateError, "HITL request already exists" if request_path.exist?
|
|
227
239
|
|
|
@@ -251,11 +263,16 @@ module Ace
|
|
|
251
263
|
# transport). Never carries answer content.
|
|
252
264
|
def read(id)
|
|
253
265
|
request_id = safe_id(id)
|
|
266
|
+
terminal = load_terminal(request_id)
|
|
267
|
+
if terminal && operator_request?(terminal)
|
|
268
|
+
gate_read_access!(terminal)
|
|
269
|
+
return terminal.slice("id", "project", "requester", "requester_uid", "sensitive", "envelope", "state", "kind")
|
|
270
|
+
end
|
|
254
271
|
unless request_path(request_id).exist?
|
|
255
272
|
terminal = load_terminal(request_id)
|
|
256
273
|
if terminal
|
|
257
274
|
gate_read_access!(terminal)
|
|
258
|
-
return terminal.slice("id", "assignment", "attempt", "project", "requester", "sensitive", "envelope", "state", "native_delivery", "effect_receipt_ref", "publication_binding", "publication_requester_peer", "otp")
|
|
275
|
+
return terminal.slice("id", "assignment", "attempt", "project", "requester", "requester_uid", "sensitive", "envelope", "state", "native_delivery", "effect_receipt_ref", "publication_binding", "publication_requester_peer", "otp")
|
|
259
276
|
.merge("kind" => terminal.dig("envelope", "kind"))
|
|
260
277
|
end
|
|
261
278
|
end
|
|
@@ -276,6 +293,7 @@ module Ace
|
|
|
276
293
|
"publication_binding" => value["publication_binding"],
|
|
277
294
|
"publication_requester_peer" => value["publication_requester_peer"],
|
|
278
295
|
"requester" => value["requester"],
|
|
296
|
+
"requester_uid" => value["requester_uid"],
|
|
279
297
|
"envelope" => value["envelope"],
|
|
280
298
|
"state" => public_state(request_id, value),
|
|
281
299
|
"effect_receipt_ref" => effect_receipt_ref(value),
|
|
@@ -298,6 +316,8 @@ module Ace
|
|
|
298
316
|
request_id = safe_id(id)
|
|
299
317
|
replay = replay_terminal!(request_id, "consumed")
|
|
300
318
|
if replay
|
|
319
|
+
terminal = load_terminal(request_id)
|
|
320
|
+
verify_operation!(terminal, operation) if operator_request?(terminal)
|
|
301
321
|
if native_delivery
|
|
302
322
|
terminal = load_terminal(request_id)
|
|
303
323
|
raise StateError, "request was consumed locally; it has no native delivery claim" unless terminal["native_delivery"]
|
|
@@ -463,7 +483,7 @@ module Ace
|
|
|
463
483
|
verify_otp_deadline!(value)
|
|
464
484
|
write_answer(value, answer)
|
|
465
485
|
update_public(value, "answer-delivered")
|
|
466
|
-
unless value["sensitive"] == true
|
|
486
|
+
unless value["sensitive"] == true || operator_request?(value)
|
|
467
487
|
requester_uid, requester_gid = @identity.user_ids(value["requester"])
|
|
468
488
|
Effects.run(self, value, answer,
|
|
469
489
|
requester_uid: requester_uid, requester_gid: requester_gid, identity: @identity)
|
|
@@ -509,6 +529,8 @@ module Ace
|
|
|
509
529
|
ids = (requests_dir.glob("*.json") + public_dir.glob("*.json")).map { |path| path.basename(".json").to_s }.uniq.sort
|
|
510
530
|
ids.each do |id|
|
|
511
531
|
next if after && id <= after
|
|
532
|
+
terminal = load_terminal(id)
|
|
533
|
+
next if terminal && operator_request?(terminal)
|
|
512
534
|
value = AtomicJson.read(requests_dir.join("#{id}.json"))
|
|
513
535
|
active = value.is_a?(Hash)
|
|
514
536
|
value = AtomicJson.read(public_dir.join("#{id}.json")) unless active
|
|
@@ -521,7 +543,7 @@ module Ace
|
|
|
521
543
|
end
|
|
522
544
|
if active
|
|
523
545
|
value = value.slice("id", "assignment", "attempt", "project", "harness", "kind", "sensitive", "plan", "question",
|
|
524
|
-
"options", "ace_hitl_id", "requester", "created_at", "otp", "envelope")
|
|
546
|
+
"options", "ace_hitl_id", "requester", "requester_uid", "created_at", "otp", "envelope")
|
|
525
547
|
.merge("state" => public_state(value["id"], value), "has_effect" => !value["effect"].nil?)
|
|
526
548
|
end
|
|
527
549
|
yield value
|
|
@@ -533,7 +555,16 @@ module Ace
|
|
|
533
555
|
def states
|
|
534
556
|
require_transport!("states")
|
|
535
557
|
public_dir.glob("*.json").sort.filter_map do |path|
|
|
536
|
-
AtomicJson.read(path)
|
|
558
|
+
value = AtomicJson.read(path)
|
|
559
|
+
next unless value.is_a?(Hash)
|
|
560
|
+
if operator_request?(value)
|
|
561
|
+
begin
|
|
562
|
+
require_transport!("states", value)
|
|
563
|
+
rescue PermissionError
|
|
564
|
+
next
|
|
565
|
+
end
|
|
566
|
+
end
|
|
567
|
+
value
|
|
537
568
|
end
|
|
538
569
|
end
|
|
539
570
|
|
|
@@ -619,6 +650,13 @@ module Ace
|
|
|
619
650
|
"envelope" => value["envelope"],
|
|
620
651
|
"updated_at" => Time.now.to_i
|
|
621
652
|
}
|
|
653
|
+
if operator_request?(value)
|
|
654
|
+
public.delete("assignment")
|
|
655
|
+
public.delete("attempt")
|
|
656
|
+
public["requester"] = value.fetch("requester")
|
|
657
|
+
public["requester_uid"] = value.fetch("requester_uid")
|
|
658
|
+
public["sensitive"] = false
|
|
659
|
+
end
|
|
622
660
|
public["incarnation"] = value["incarnation"] if value["incarnation"]
|
|
623
661
|
public["native_delivery"] = true if value["native_delivery"]
|
|
624
662
|
public["otp"] = public_otp_projection(value["otp"]) if value["otp"]
|
|
@@ -771,6 +809,7 @@ module Ace
|
|
|
771
809
|
end
|
|
772
810
|
|
|
773
811
|
def verify_native_consumer!(value)
|
|
812
|
+
raise StateError, "operator question has no native execution claim" if operator_request?(value)
|
|
774
813
|
raise StateError, "OTP requires protected local consume" if value["sensitive"] == true
|
|
775
814
|
reverse = @binding.reverse_address(assignment: value["assignment"], project: value["project"], attempt: value["attempt"],
|
|
776
815
|
caller_pid: @identity.respond_to?(:pid) ? @identity.pid : nil)
|
|
@@ -831,6 +870,12 @@ module Ace
|
|
|
831
870
|
# ---- role gates ------------------------------------------------------
|
|
832
871
|
|
|
833
872
|
def requester_gate!(value)
|
|
873
|
+
if operator_request?(value)
|
|
874
|
+
operator_authority!(value)
|
|
875
|
+
unless @identity.uid == value.fetch("requester_uid")
|
|
876
|
+
raise PermissionError, "only the kernel requester UID can consume this question"
|
|
877
|
+
end
|
|
878
|
+
end
|
|
834
879
|
unless @identity.username == value["requester"].to_s
|
|
835
880
|
raise PermissionError, "only the requesting role can consume this answer"
|
|
836
881
|
end
|
|
@@ -849,6 +894,7 @@ module Ace
|
|
|
849
894
|
end
|
|
850
895
|
|
|
851
896
|
def require_transport!(operation, value = nil)
|
|
897
|
+
operator_authority!(value) if value && operator_request?(value)
|
|
852
898
|
return true if @policy.transport?(@identity, project: value && value["project"])
|
|
853
899
|
|
|
854
900
|
raise PermissionError, "#{operation} requires the configured transport identity"
|
|
@@ -858,6 +904,9 @@ module Ace
|
|
|
858
904
|
# challenge's authorized operation; anything else fails closed
|
|
859
905
|
# without touching the secret.
|
|
860
906
|
def verify_operation!(value, operation)
|
|
907
|
+
if operator_request?(value) && operation
|
|
908
|
+
raise StateError, "operator question has no authorized execution operation"
|
|
909
|
+
end
|
|
861
910
|
return unless value["otp"]
|
|
862
911
|
|
|
863
912
|
presented = operation.to_s
|
|
@@ -886,7 +935,7 @@ module Ace
|
|
|
886
935
|
raise PermissionError, "only the requesting role can #{operation == "cancelled" ? "cancel" : "consume"} this request"
|
|
887
936
|
end
|
|
888
937
|
|
|
889
|
-
requester_gate!(terminal) if terminal["publication_binding"]
|
|
938
|
+
requester_gate!(terminal) if terminal["publication_binding"] || operator_request?(terminal)
|
|
890
939
|
case terminal["state"]
|
|
891
940
|
when "cancelled"
|
|
892
941
|
return replayed_cancel(request_id, terminal) if operation == "cancelled"
|
|
@@ -947,6 +996,10 @@ module Ace
|
|
|
947
996
|
"effect_receipt_ref" => effect_receipt_ref(value),
|
|
948
997
|
"at" => Time.now.to_i
|
|
949
998
|
}
|
|
999
|
+
if operator_request?(value)
|
|
1000
|
+
record["requester_uid"] = value.fetch("requester_uid")
|
|
1001
|
+
record["kind"] = "question"
|
|
1002
|
+
end
|
|
950
1003
|
%w[publication_binding publication_requester_peer otp].each do |key|
|
|
951
1004
|
record[key] = value[key] if value.key?(key)
|
|
952
1005
|
end
|
|
@@ -968,7 +1021,13 @@ module Ace
|
|
|
968
1021
|
# the request stays pending and the classified error propagates
|
|
969
1022
|
# for retry (review 8x32r9b0).
|
|
970
1023
|
def with_live_authority!(value, requester:)
|
|
971
|
-
if value
|
|
1024
|
+
if operator_request?(value)
|
|
1025
|
+
operator_authority!(value)
|
|
1026
|
+
if @identity.uid != value.fetch("requester_uid")
|
|
1027
|
+
require_transport!("operator transition", value)
|
|
1028
|
+
end
|
|
1029
|
+
yield
|
|
1030
|
+
elsif value["publication_binding"]
|
|
972
1031
|
unless @binding.respond_to?(:with_publication!)
|
|
973
1032
|
raise BindingError, "publication HITL source binding is unavailable"
|
|
974
1033
|
end
|
|
@@ -10,18 +10,33 @@ module Ace
|
|
|
10
10
|
# even when issued by the same CA, and no operation against any
|
|
11
11
|
# other project is reachable through this authority.
|
|
12
12
|
class TlsAccessPolicy < AccessPolicy
|
|
13
|
-
def initialize(project:)
|
|
13
|
+
def initialize(project:, decision_policy: AccessPolicy.new)
|
|
14
14
|
@project = project.to_s
|
|
15
|
+
@decision_policy = decision_policy
|
|
15
16
|
end
|
|
16
17
|
|
|
17
18
|
def transport?(peer, project: nil)
|
|
18
19
|
bound?(peer) && (project.nil? || project.to_s == @project)
|
|
19
20
|
end
|
|
20
21
|
|
|
22
|
+
def operator_question?(uid:, project:)
|
|
23
|
+
project == @project && @decision_policy.operator_question?(uid: uid, project: project)
|
|
24
|
+
end
|
|
25
|
+
|
|
21
26
|
def proposal?(peer, project:)
|
|
22
27
|
transport?(peer, project: project)
|
|
23
28
|
end
|
|
24
29
|
|
|
30
|
+
def proposal_silence?(caller_uid:, requester:, project:, operation:)
|
|
31
|
+
return false unless project == @project
|
|
32
|
+
# The transport peer is authenticated separately. A local proposal
|
|
33
|
+
# retains its original kernel principal when Hermes reconciles it.
|
|
34
|
+
return @decision_policy.proposal_silence?(caller_uid: caller_uid, requester: requester,
|
|
35
|
+
project: project, operation: operation) unless caller_uid.nil?
|
|
36
|
+
requester == "urn:ace:hitl:#{@project}:hermes-client" &&
|
|
37
|
+
@decision_policy.silence_operation?(project: project, operation: operation)
|
|
38
|
+
end
|
|
39
|
+
|
|
25
40
|
# A TLS authority has no Unix service uid; client endpoint
|
|
26
41
|
# verification is certificate-based.
|
|
27
42
|
def service_uid
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "ipaddr"
|
|
4
4
|
require "json"
|
|
5
|
+
require "ace/runtime/molecules/protected_artifact_set"
|
|
5
6
|
require_relative "tls_credentials"
|
|
6
7
|
require_relative "credential_path_policy"
|
|
7
8
|
require_relative "tls_material"
|
|
@@ -22,16 +23,24 @@ module Ace
|
|
|
22
23
|
SCHEMA = "ace.hitl.tls.authority/v1"
|
|
23
24
|
FIELDS = %w[schema project_id listen port service_user service_group
|
|
24
25
|
tls_server_credential].freeze
|
|
26
|
+
MAX_BYTES = 65_536
|
|
25
27
|
|
|
26
28
|
attr_reader :project_id, :listen, :port, :service_user, :service_group,
|
|
27
29
|
:credential, :path
|
|
28
30
|
|
|
29
31
|
def self.load(path)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
reader = Ace::Runtime::Molecules::ProtectedArtifactSet.new(file_limit: MAX_BYTES, total_limit: MAX_BYTES)
|
|
33
|
+
reader.with do |held|
|
|
34
|
+
bytes, = held.read_path!(path)
|
|
35
|
+
config = JSON.parse(bytes, create_additions: false, allow_duplicate_key: false, max_nesting: 16)
|
|
36
|
+
authority = new(config, path: path)
|
|
37
|
+
CredentialPathPolicy.verify_runtime_file!(path, service_group: authority.service_group)
|
|
38
|
+
# Exact mode/group validation and parsing must refer to the same
|
|
39
|
+
# bounded protected inode, including every retained ancestor.
|
|
40
|
+
held.verify_unchanged!
|
|
41
|
+
authority
|
|
42
|
+
end
|
|
43
|
+
rescue JSON::ParserError, SystemCallError, IOError, Ace::Runtime::Error
|
|
35
44
|
raise TransportError, "the HITL TLS authority configuration is unavailable or malformed: #{path}"
|
|
36
45
|
end
|
|
37
46
|
|
|
@@ -35,7 +35,7 @@ module Ace
|
|
|
35
35
|
|
|
36
36
|
def initialize(root:, binding:, project:, credential:, ssl_context:,
|
|
37
37
|
listen: "127.0.0.1", port:, group: Store::DEFAULT_GROUP, vault: nil, logger: nil,
|
|
38
|
-
idle_seconds: DEFAULT_IDLE_SECONDS, proposal_clock: -> { Time.now.utc })
|
|
38
|
+
idle_seconds: DEFAULT_IDLE_SECONDS, proposal_clock: -> { Time.now.utc }, decision_policy: AccessPolicy.new)
|
|
39
39
|
@root = root
|
|
40
40
|
@binding = binding
|
|
41
41
|
@project = TlsCredentials.project_id(project)
|
|
@@ -48,6 +48,7 @@ module Ace
|
|
|
48
48
|
@logger = logger
|
|
49
49
|
@idle_seconds = idle_seconds
|
|
50
50
|
@proposal_clock = proposal_clock
|
|
51
|
+
@policy = TlsAccessPolicy.new(project: @project, decision_policy: decision_policy)
|
|
51
52
|
@max_connections = MAX_CONNECTIONS
|
|
52
53
|
@connections = 0
|
|
53
54
|
@handler_threads = []
|
|
@@ -223,7 +224,7 @@ module Ace
|
|
|
223
224
|
Store.new(
|
|
224
225
|
root: @root,
|
|
225
226
|
binding: @binding,
|
|
226
|
-
policy:
|
|
227
|
+
policy: @policy,
|
|
227
228
|
identity: peer,
|
|
228
229
|
vault: @vault,
|
|
229
230
|
proposal_clock: @proposal_clock, stopping: -> { @stopping }, group: @group
|
|
@@ -233,7 +234,7 @@ module Ace
|
|
|
233
234
|
end
|
|
234
235
|
|
|
235
236
|
def root_store
|
|
236
|
-
Store.new(root: @root, binding: @binding, policy:
|
|
237
|
+
Store.new(root: @root, binding: @binding, policy: @policy, group: @group)
|
|
237
238
|
end
|
|
238
239
|
end
|
|
239
240
|
end
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -108,8 +108,10 @@ module Ace
|
|
|
108
108
|
|
|
109
109
|
def resolve(record, checkpoint:, now:)
|
|
110
110
|
return record unless record["state"] == "awaiting-decision"
|
|
111
|
+
return record unless record["silence_delegated"] == true
|
|
111
112
|
return record if timestamp(now) < timestamp(record.fetch("deadline"))
|
|
112
113
|
valid = checkpoint["schema"] == "ace.hitl.hermes.ingress-checkpoint/v1" &&
|
|
114
|
+
checkpoint["project"].is_a?(String) && checkpoint["project"] == record["project_id"] &&
|
|
113
115
|
checkpoint["request"] == record["request_id"] && checkpoint["revision"] == record["revision_id"] &&
|
|
114
116
|
checkpoint["healthy"] == true && checkpoint["drained"] == true &&
|
|
115
117
|
checkpoint.dig("checkpoint", "through") == record["deadline"] &&
|
|
@@ -16,9 +16,14 @@ module Ace
|
|
|
16
16
|
# authority. Every doubt maps to the classified BindingError —
|
|
17
17
|
# unknown identity/authority is an error, never permission.
|
|
18
18
|
class AssignmentBinding < Lifecycle::Binding
|
|
19
|
-
def initialize(coordinator: nil, repo_root: nil)
|
|
19
|
+
def initialize(coordinator: nil, repo_root: nil, project: nil, owner_guard: nil)
|
|
20
|
+
unless (project.nil? && owner_guard.nil?) ||
|
|
21
|
+
(project.is_a?(String) && !project.empty? && owner_guard.respond_to?(:call))
|
|
22
|
+
raise ArgumentError, "installed binding needs both a fixed project and its owner guard"
|
|
23
|
+
end
|
|
20
24
|
@coordinator = coordinator
|
|
21
25
|
@repo_root = repo_root
|
|
26
|
+
@project, @owner_guard = project, owner_guard
|
|
22
27
|
end
|
|
23
28
|
|
|
24
29
|
def validate_request(assignment:, attempt:, project:, requester:, caller_pid: nil)
|
|
@@ -30,6 +35,7 @@ module Ace
|
|
|
30
35
|
# Read-only owner verification can run inside an already-held
|
|
31
36
|
# assignment exclusion without reacquiring that exclusion.
|
|
32
37
|
def reverse_address(assignment:, attempt:, project:, caller_pid:)
|
|
38
|
+
verify_selection!(project)
|
|
33
39
|
unless caller_pid.is_a?(Integer) && caller_pid.positive?
|
|
34
40
|
raise Lifecycle::BindingError, "kernel requester process identity is unavailable"
|
|
35
41
|
end
|
|
@@ -49,15 +55,26 @@ module Ace
|
|
|
49
55
|
unless project.is_a?(String) && !project.empty?
|
|
50
56
|
raise Lifecycle::BindingError, "proposal requires an explicit project"
|
|
51
57
|
end
|
|
58
|
+
verify_selection!(project)
|
|
52
59
|
coordinator.send(:journal_for)
|
|
53
60
|
end
|
|
54
61
|
|
|
55
62
|
def proposal_projects
|
|
63
|
+
if @project
|
|
64
|
+
verify_selection!(@project)
|
|
65
|
+
return [@project]
|
|
66
|
+
end
|
|
56
67
|
coordinator.send(:journal_for).proposals.map { |record| record.fetch("project_id") }.uniq.sort
|
|
57
68
|
end
|
|
58
69
|
|
|
59
70
|
private
|
|
60
71
|
|
|
72
|
+
def verify_selection!(project)
|
|
73
|
+
return unless @project
|
|
74
|
+
raise Lifecycle::BindingError, "project differs from installed HITL owner" unless project == @project
|
|
75
|
+
@owner_guard.call
|
|
76
|
+
end
|
|
77
|
+
|
|
61
78
|
def with_verified(assignment_id, attempt_id, project_id, requester)
|
|
62
79
|
# ONLY the authority verification is error-mapped: the
|
|
63
80
|
# caller's transition (answer hand-off, commits, effects)
|
|
@@ -66,12 +83,14 @@ module Ace
|
|
|
66
83
|
# failure (review 8x327bud). The exclusion still spans the
|
|
67
84
|
# whole transition, so the phase flag separates the two.
|
|
68
85
|
verifying = true
|
|
86
|
+
verify_selection!(project_id)
|
|
69
87
|
coordinator.with_verified_attempt(
|
|
70
88
|
assignment_id: assignment_id,
|
|
71
89
|
attempt_id: attempt_id,
|
|
72
90
|
project_id: project_id,
|
|
73
91
|
requester: requester
|
|
74
92
|
) do |attempt|
|
|
93
|
+
verify_selection!(project_id)
|
|
75
94
|
verifying = false
|
|
76
95
|
yield attempt
|
|
77
96
|
end
|
data/lib/ace/hitl/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ace-hitl
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.17.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michal Czyz
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-10-
|
|
10
|
+
date: 2026-10-16 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: ace-hitl-contract
|
|
@@ -15,14 +15,14 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - "~>"
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: 0.
|
|
18
|
+
version: 0.3.0
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: 0.
|
|
25
|
+
version: 0.3.0
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: ace-support-core
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -113,28 +113,42 @@ dependencies:
|
|
|
113
113
|
requirements:
|
|
114
114
|
- - "~>"
|
|
115
115
|
- !ruby/object:Gem::Version
|
|
116
|
-
version: '0.
|
|
116
|
+
version: '0.70'
|
|
117
117
|
type: :runtime
|
|
118
118
|
prerelease: false
|
|
119
119
|
version_requirements: !ruby/object:Gem::Requirement
|
|
120
120
|
requirements:
|
|
121
121
|
- - "~>"
|
|
122
122
|
- !ruby/object:Gem::Version
|
|
123
|
-
version: '0.
|
|
123
|
+
version: '0.70'
|
|
124
124
|
- !ruby/object:Gem::Dependency
|
|
125
125
|
name: ace-herdr
|
|
126
126
|
requirement: !ruby/object:Gem::Requirement
|
|
127
127
|
requirements:
|
|
128
128
|
- - "~>"
|
|
129
129
|
- !ruby/object:Gem::Version
|
|
130
|
-
version: 0.
|
|
130
|
+
version: '0.6'
|
|
131
131
|
type: :runtime
|
|
132
132
|
prerelease: false
|
|
133
133
|
version_requirements: !ruby/object:Gem::Requirement
|
|
134
134
|
requirements:
|
|
135
135
|
- - "~>"
|
|
136
136
|
- !ruby/object:Gem::Version
|
|
137
|
-
version: 0.
|
|
137
|
+
version: '0.6'
|
|
138
|
+
- !ruby/object:Gem::Dependency
|
|
139
|
+
name: ace-runtime
|
|
140
|
+
requirement: !ruby/object:Gem::Requirement
|
|
141
|
+
requirements:
|
|
142
|
+
- - "~>"
|
|
143
|
+
- !ruby/object:Gem::Version
|
|
144
|
+
version: '0.3'
|
|
145
|
+
type: :runtime
|
|
146
|
+
prerelease: false
|
|
147
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
148
|
+
requirements:
|
|
149
|
+
- - "~>"
|
|
150
|
+
- !ruby/object:Gem::Version
|
|
151
|
+
version: '0.3'
|
|
138
152
|
description: Provides the dedicated ace-hitl package surface for human in the loop
|
|
139
153
|
(HITL) semantics and CLI workflows.
|
|
140
154
|
email:
|
|
@@ -170,11 +184,13 @@ files:
|
|
|
170
184
|
- lib/ace/hitl/cli/commands/duty.rb
|
|
171
185
|
- lib/ace/hitl/cli/commands/lifecycle_command.rb
|
|
172
186
|
- lib/ace/hitl/cli/commands/list.rb
|
|
187
|
+
- lib/ace/hitl/cli/commands/operator_question.rb
|
|
173
188
|
- lib/ace/hitl/cli/commands/overseer_ack.rb
|
|
174
189
|
- lib/ace/hitl/cli/commands/overseer_pending.rb
|
|
175
190
|
- lib/ace/hitl/cli/commands/overseer_send.rb
|
|
176
191
|
- lib/ace/hitl/cli/commands/pending.rb
|
|
177
192
|
- lib/ace/hitl/cli/commands/proposal.rb
|
|
193
|
+
- lib/ace/hitl/cli/commands/read.rb
|
|
178
194
|
- lib/ace/hitl/cli/commands/serve.rb
|
|
179
195
|
- lib/ace/hitl/cli/commands/show.rb
|
|
180
196
|
- lib/ace/hitl/cli/commands/states.rb
|
|
@@ -193,6 +209,7 @@ files:
|
|
|
193
209
|
- lib/ace/hitl/lifecycle/identity.rb
|
|
194
210
|
- lib/ace/hitl/lifecycle/kinds.rb
|
|
195
211
|
- lib/ace/hitl/lifecycle/lifecycle_requests.rb
|
|
212
|
+
- lib/ace/hitl/lifecycle/operator_questions.rb
|
|
196
213
|
- lib/ace/hitl/lifecycle/otp_vault.rb
|
|
197
214
|
- lib/ace/hitl/lifecycle/overseer.rb
|
|
198
215
|
- lib/ace/hitl/lifecycle/peer.rb
|