ace-hitl-contract 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 898a6d72e810e38ec458ab4c6fed1ef44f7153fb4caa4385ccbfc90dcf32c6de
4
- data.tar.gz: b31aebd8f351ae2bd240eee1a11a18532065bebfda87b005deefc66d49c59067
3
+ metadata.gz: 759e3f37e3bd513e374fbcd315d630178d48978c6e8b07da31d0b94b4a16d7e4
4
+ data.tar.gz: 4568504b1fc12a0e39e36b7f440e523588cd758d7eb0f750fa6ff4226d2008b5
5
5
  SHA512:
6
- metadata.gz: 77410b2ccb1fd4477c953a081165427e6ab587540df113cb2806314131c0e3577e17b7f32931c5983230c6021cd7b494169aea4ff93d3bf7fa52e7ec7af11379
7
- data.tar.gz: ddb96c185b8e5c4a1ab74e0a5328131773f9dd2369deeecf0041bd6373ba884e177062c465a807143ad8594fd7c093006c3fdfa2386ba082038daf3f153e656f
6
+ metadata.gz: 77635aadf48b9cb6f79039688b61e440b0977b243d3dc846e138478201c8b4c76066a3139488d2de2d91de0c75bcf975134719ed6bf2cd57e00e2842c356b090
7
+ data.tar.gz: 912c8dfb1cd803292bac777a18f4ce81b502240e359b1a606575d9c7f99ce2544ef800207d490de4fea246c304dc7c7d2adf37f7f256991d52c45028f4cda4a0
data/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.2.0] - 2026-10-05
6
+
7
+ ### Added
8
+ - Resolve immutable second-commander proposals through confirmed-delivery sixteen-hour policy and canonical Assign authorization.
9
+
10
+ ### Fixed
11
+ - Validate whole Herdr/native tmux reverse-reference pairs consistently, preserving exact `$session`/`%pane` IDs and rejecting mixed, noncanonical wire and unclassified encoding inputs.
12
+ - Reject non-string payload digests and impossible nested calendar timestamps consistently with the Hermes message contract.
13
+
14
+ ### Changed
15
+ - Publish the pure managed/v1 envelope codec, incarnation-bound Inbox event identity, distinct nested transport/reverse schemas, shared secret gate and packaged managed/ingress observation examples without adding runtime dependencies.
16
+
5
17
  ## [0.1.0] - 2026-10-04
6
18
 
7
19
  ### Added
data/README.md CHANGED
@@ -11,4 +11,45 @@ result = Ace::Hitl::Providers::DeliverResult.new(ref: ref, state: :delivered)
11
11
 
12
12
  The protocol owns `Ref`, `AskResult`, `DeliverResult` and the provider error hierarchy under `Ace::Hitl::Providers`. Full registry and locked assignment authority remain in `ace-hitl`. Runtime adapters use this leaf package without loading the privileged HITL lifecycle.
13
13
 
14
+ `Ref.new(session: "$0", pane: "%0")` preserves paired native tmux IDs; existing
15
+ Herdr token pairs such as `workspace:1`/`pane:1` remain valid. Native IDs use canonical
16
+ nonnegative decimal digits (no leading zero except 0) and must appear together.
17
+ Mixed or cross-field IDs, non-String values, non-ASCII-compatible encodings and
18
+ components over 128 characters raise `InvalidRefError`. Direct construction and
19
+ `from_env` strip outer whitespace; `canonical: true` refuses it for wire/persisted
20
+ addresses. Diagnostic `session_source`/`pane_source` labels never select grammar.
21
+ Consumers validate the whole pair through the constructor; component-only
22
+ `Ref.validate!` is removed. Valid syntax never grants target/attempt authority or
23
+ adds tmux execution to a Herdr controller.
24
+
14
25
  Run package tests from the ACE checkout with `bin/ace-test ace-hitl-contract all`.
26
+
27
+ `Ace::Hitl::Contract::ManagedEnvelope.load(json, expected: {...})` validates
28
+ `ace.hitl.managed/v1` and exact expected assignment, attempt and correlation
29
+ fields. The installed gem includes `managed.v1.schema.json` and shared examples
30
+ under `lib/ace/hitl/contract/examples`. HITL owns envelope semantics; this leaf
31
+ contains only the pure wire codec, with no dependency on Herdr, assignment or
32
+ the privileged service.
33
+
34
+ The nested `ace.hitl.ref/v1` reverse address and Hermes `message/v1` retain their
35
+ own schemas. A nil reverse address explicitly selects pane-less consumption.
36
+ Ordinary payload digests refer to the exact UTF-8 payload bytes. OTP envelopes
37
+ contain neither answer bytes nor their digest; folder answers and callback
38
+ effects are prohibited for that kind. A non-secret OTP question remains valid.
39
+ Effect references identify authorization and result evidence; structural
40
+ validation never grants authority or proves business success.
41
+
42
+ `examples/ingress-checkpoints.json` publishes separate y24 ingress observation
43
+ examples: healthy/drained permits only a coverage-backed absence claim; healthy
44
+ with unresolved ingress is not drained; unknown has no usable checkpoint. These
45
+ transport observations are not native consumption or business effect receipts.
46
+ The ingress `received_at` belongs to its accepted transport ingress item.
47
+
48
+ The opt-in `test/edge/reverse_schema_test.rb` checks the complete published schema
49
+ and runtime codec against the same native/Herdr and malformed-pair matrix using
50
+ Python `jsonschema==4.25.1` (Draft202012Validator). Install that test-only validator
51
+ in an isolated venv and supply its Python executable through runner fixture
52
+ configuration `environment.overrides.ACE_HITL_SCHEMA_PYTHON`; run the absolute
53
+ test file with `bin/ace-test ace-hitl-contract ... --config-path /absolute/fixture.yml`.
54
+ The default package run skips this external validator check; no runtime gem
55
+ dependency or production configuration is added.
@@ -0,0 +1,50 @@
1
+ {
2
+ "cases": [
3
+ {
4
+ "schema": "ace.hitl.hermes.ingress-checkpoint/v1",
5
+ "request": "hitl-abcdef1234",
6
+ "revision": "def456",
7
+ "channel": "ace-captain",
8
+ "status": "unknown",
9
+ "healthy": false,
10
+ "drained": false,
11
+ "checkpoint": null,
12
+ "unresolved": []
13
+ },
14
+ {
15
+ "schema": "ace.hitl.hermes.ingress-checkpoint/v1",
16
+ "request": "hitl-abcdef1234",
17
+ "revision": "def456",
18
+ "channel": "ace-captain",
19
+ "status": "healthy",
20
+ "healthy": true,
21
+ "drained": true,
22
+ "checkpoint": {
23
+ "through": "2026-10-05T10:00:00Z",
24
+ "sequence": 4
25
+ },
26
+ "unresolved": []
27
+ },
28
+ {
29
+ "schema": "ace.hitl.hermes.ingress-checkpoint/v1",
30
+ "request": "hitl-abcdef1234",
31
+ "revision": "def456",
32
+ "channel": "ace-captain",
33
+ "status": "healthy",
34
+ "healthy": true,
35
+ "drained": false,
36
+ "checkpoint": {
37
+ "through": "2026-10-05T10:00:00Z",
38
+ "sequence": 5
39
+ },
40
+ "unresolved": [
41
+ {
42
+ "request": "hitl-abcdef1234",
43
+ "revision": "def456",
44
+ "received_at": "2026-10-05T09:59:59Z",
45
+ "status": "unresolved"
46
+ }
47
+ ]
48
+ }
49
+ ]
50
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "schema": "ace.hitl.managed/v1",
3
+ "request_id": "hitl-abcdef1234",
4
+ "project": "ace",
5
+ "assignment_id": "abc123",
6
+ "attempt_id": "def456",
7
+ "requester": "lab-admin",
8
+ "correlation_id": "hitl-abcdef1234",
9
+ "kind": "decision",
10
+ "reverse": {
11
+ "schema": "ace.hitl.ref/v1",
12
+ "session": "workspace:1",
13
+ "pane": "pane:1"
14
+ },
15
+ "payload_sha256": "f01847dfc94126426b4849004762a12557de2332ae0ca1b4602210b341f0281b",
16
+ "message": {
17
+ "schema": "ace.hitl.hermes.message/v1",
18
+ "id": "hitl-abcdef1234",
19
+ "kind": "answer",
20
+ "sender": "captain",
21
+ "answer": "Proceed",
22
+ "received_at": "2026-10-05T10:00:00Z"
23
+ },
24
+ "request_incarnation": "0123456789abcdef"
25
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "schema": "ace.hitl.managed/v1",
3
+ "request_id": "hitl-abcdef1234",
4
+ "project": "ace",
5
+ "assignment_id": "abc123",
6
+ "attempt_id": "def456",
7
+ "requester": "lab-admin",
8
+ "correlation_id": "hitl-abcdef1234",
9
+ "kind": "otp",
10
+ "reverse": null,
11
+ "message": {
12
+ "schema": "ace.hitl.hermes.message/v1",
13
+ "id": "hitl-abcdef1234",
14
+ "kind": "question",
15
+ "sender": "captain",
16
+ "question": "Provide the code for the authorized operation",
17
+ "created_at": "2026-10-05T09:00:00Z"
18
+ },
19
+ "request_incarnation": "0123456789abcdef"
20
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "schema": "ace.hitl.managed/v1",
3
+ "request_id": "hitl-abcdef1234",
4
+ "project": "ace",
5
+ "assignment_id": "abc123",
6
+ "attempt_id": "def456",
7
+ "requester": "lab-admin",
8
+ "correlation_id": "hitl-abcdef1234",
9
+ "kind": "decision",
10
+ "reverse": {
11
+ "schema": "ace.hitl.ref/v1",
12
+ "session": "workspace:1",
13
+ "pane": "pane:1"
14
+ },
15
+ "payload_sha256": "7d2a89fa56032bf20bd4f17f72c81fc49ad0a202dd5c8e563e083fdf24239b4c",
16
+ "message": {
17
+ "schema": "ace.hitl.hermes.message/v1",
18
+ "id": "hitl-abcdef1234",
19
+ "kind": "question",
20
+ "sender": "captain",
21
+ "question": "Proceed?",
22
+ "created_at": "2026-10-05T09:00:00Z"
23
+ },
24
+ "request_incarnation": "0123456789abcdef"
25
+ }
@@ -0,0 +1,262 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "ace.hitl.managed/v1",
4
+ "title": "Managed HITL request and delivery metadata",
5
+ "description": "HITL owns semantics. Structural validity never grants actor/effect authority. OTP bytes and their digest are prohibited. Nested transport and reverse schemas remain distinct.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": [
9
+ "schema",
10
+ "request_id",
11
+ "project",
12
+ "assignment_id",
13
+ "attempt_id",
14
+ "requester",
15
+ "correlation_id",
16
+ "kind",
17
+ "reverse",
18
+ "request_incarnation"
19
+ ],
20
+ "properties": {
21
+ "schema": {
22
+ "const": "ace.hitl.managed/v1"
23
+ },
24
+ "request_id": {
25
+ "type": "string",
26
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
27
+ },
28
+ "project": {
29
+ "type": "string",
30
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
31
+ },
32
+ "requester": {
33
+ "type": "string",
34
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
35
+ },
36
+ "correlation_id": {
37
+ "type": "string",
38
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
39
+ },
40
+ "assignment_id": {
41
+ "type": "string",
42
+ "pattern": "^[0-9a-z][0-9a-z]{4,63}$"
43
+ },
44
+ "attempt_id": {
45
+ "type": "string",
46
+ "pattern": "^[0-9a-z][0-9a-z]{4,63}$"
47
+ },
48
+ "kind": {
49
+ "enum": [
50
+ "text",
51
+ "choice",
52
+ "confirm",
53
+ "review",
54
+ "question",
55
+ "decision",
56
+ "verification",
57
+ "otp"
58
+ ]
59
+ },
60
+ "reverse": {
61
+ "oneOf": [
62
+ {
63
+ "type": "null"
64
+ },
65
+ {
66
+ "type": "object",
67
+ "additionalProperties": false,
68
+ "required": [
69
+ "schema",
70
+ "session",
71
+ "pane"
72
+ ],
73
+ "properties": {
74
+ "schema": {
75
+ "const": "ace.hitl.ref/v1"
76
+ },
77
+ "session": {
78
+ "type": "string",
79
+ "minLength": 1,
80
+ "maxLength": 128
81
+ },
82
+ "pane": {
83
+ "type": "string",
84
+ "minLength": 1,
85
+ "maxLength": 128
86
+ }
87
+ },
88
+ "oneOf": [
89
+ {
90
+ "properties": {
91
+ "session": { "pattern": "^[A-Za-z0-9._:-]+$(?![\\s\\S])" },
92
+ "pane": { "pattern": "^[A-Za-z0-9._:-]+$(?![\\s\\S])" }
93
+ }
94
+ },
95
+ {
96
+ "properties": {
97
+ "session": { "pattern": "^\\$(?:0|[1-9][0-9]*)$(?![\\s\\S])" },
98
+ "pane": { "pattern": "^%(?:0|[1-9][0-9]*)$(?![\\s\\S])" }
99
+ }
100
+ }
101
+ ]
102
+ }
103
+ ]
104
+ },
105
+ "payload_sha256": {
106
+ "type": "string",
107
+ "pattern": "^[0-9a-f]{64}$"
108
+ },
109
+ "message": {
110
+ "type": "object",
111
+ "description": "Nested ace.hitl.hermes.message/v1, exact id=correlation_id; runtime codec additionally rejects secret answers and digest mismatch.",
112
+ "additionalProperties": false,
113
+ "required": [
114
+ "schema",
115
+ "id",
116
+ "kind",
117
+ "sender"
118
+ ],
119
+ "properties": {
120
+ "schema": {
121
+ "const": "ace.hitl.hermes.message/v1"
122
+ },
123
+ "id": {
124
+ "type": "string"
125
+ },
126
+ "kind": {
127
+ "enum": [
128
+ "question",
129
+ "answer"
130
+ ]
131
+ },
132
+ "sender": {
133
+ "type": "string"
134
+ },
135
+ "question": {
136
+ "type": "string",
137
+ "minLength": 1
138
+ },
139
+ "answer": {
140
+ "type": "string",
141
+ "minLength": 1
142
+ },
143
+ "created_at": {
144
+ "type": "string"
145
+ },
146
+ "received_at": {
147
+ "type": "string"
148
+ }
149
+ },
150
+ "oneOf": [
151
+ {
152
+ "properties": {
153
+ "kind": {
154
+ "const": "question"
155
+ }
156
+ },
157
+ "required": [
158
+ "question",
159
+ "created_at"
160
+ ],
161
+ "not": {
162
+ "anyOf": [
163
+ {
164
+ "required": [
165
+ "answer"
166
+ ]
167
+ },
168
+ {
169
+ "required": [
170
+ "received_at"
171
+ ]
172
+ }
173
+ ]
174
+ }
175
+ },
176
+ {
177
+ "properties": {
178
+ "kind": {
179
+ "const": "answer"
180
+ }
181
+ },
182
+ "required": [
183
+ "answer",
184
+ "received_at"
185
+ ],
186
+ "not": {
187
+ "anyOf": [
188
+ {
189
+ "required": [
190
+ "question"
191
+ ]
192
+ },
193
+ {
194
+ "required": [
195
+ "created_at"
196
+ ]
197
+ }
198
+ ]
199
+ }
200
+ }
201
+ ]
202
+ },
203
+ "effect": {
204
+ "type": "object",
205
+ "additionalProperties": false,
206
+ "required": [
207
+ "authorization_ref"
208
+ ],
209
+ "properties": {
210
+ "authorization_ref": {
211
+ "type": "string",
212
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
213
+ },
214
+ "receipt_ref": {
215
+ "type": "string",
216
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
217
+ }
218
+ }
219
+ },
220
+ "request_incarnation": {
221
+ "type": "string",
222
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
223
+ }
224
+ },
225
+ "if": {
226
+ "properties": {
227
+ "kind": {
228
+ "const": "otp"
229
+ }
230
+ }
231
+ },
232
+ "then": {
233
+ "not": {
234
+ "anyOf": [
235
+ {
236
+ "required": [
237
+ "payload_sha256"
238
+ ]
239
+ },
240
+ {
241
+ "required": [
242
+ "effect"
243
+ ]
244
+ }
245
+ ]
246
+ },
247
+ "properties": {
248
+ "message": {
249
+ "properties": {
250
+ "kind": {
251
+ "const": "question"
252
+ }
253
+ }
254
+ }
255
+ }
256
+ },
257
+ "else": {
258
+ "required": [
259
+ "payload_sha256"
260
+ ]
261
+ }
262
+ }
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+ require "time"
6
+ require_relative "ref"
7
+ require_relative "secret_gate"
8
+
9
+ module Ace
10
+ module Hitl
11
+ module Contract
12
+ class InvalidEnvelope < ArgumentError; end
13
+
14
+ # Pure wire vocabulary, owned semantically by HITL. Validation proves
15
+ # structure and exact correlation only, never actor/effect authority.
16
+ # Transport message.v1 and Herdr's reverse address stay separate schemas.
17
+ class ManagedEnvelope
18
+ SCHEMA = "ace.hitl.managed/v1"
19
+ REQUIRED = %w[schema request_id request_incarnation project assignment_id attempt_id requester correlation_id kind reverse].freeze
20
+ OPTIONAL = %w[payload_sha256 message effect].freeze
21
+ TOKEN = /\A[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\z/
22
+ COMPACT = /\A[0-9a-z][0-9a-z]{4,63}\z/
23
+ DIGEST = /\A[0-9a-f]{64}\z/
24
+ KINDS = %w[text choice confirm review question decision verification otp proposal].freeze
25
+ MESSAGE_SCHEMA = "ace.hitl.hermes.message/v1"
26
+
27
+ def self.inbox_event_id(value)
28
+ data = load(value)
29
+ raise InvalidEnvelope, "OTP has no ordinary native inbox event" if data["kind"] == "otp"
30
+ "inb-#{Digest::SHA256.hexdigest([data['request_id'], data['request_incarnation']].join(':'))[0, 32]}"
31
+ end
32
+
33
+ def self.load(value, expected: {})
34
+ data = value.is_a?(String) ? JSON.parse(value) : JSON.parse(JSON.generate(value))
35
+ raise InvalidEnvelope, "managed envelope must be an object" unless data.is_a?(Hash)
36
+ missing = REQUIRED - data.keys
37
+ unknown = data.keys - REQUIRED - OPTIONAL
38
+ raise InvalidEnvelope, "managed envelope fields differ (missing #{missing.join(',')}; unknown #{unknown.join(',')})" unless missing.empty? && unknown.empty?
39
+ raise InvalidEnvelope, "unsupported managed envelope version" unless data["schema"] == SCHEMA
40
+ %w[request_id request_incarnation project requester correlation_id].each { |key| token!(data[key], key) }
41
+ %w[assignment_id attempt_id].each do |key|
42
+ raise InvalidEnvelope, "invalid #{key}" unless data[key].is_a?(String) && COMPACT.match?(data[key])
43
+ end
44
+ raise InvalidEnvelope, "unsupported managed request kind" unless KINDS.include?(data["kind"])
45
+ reverse!(data["reverse"])
46
+ if data["kind"] == "otp"
47
+ if data.key?("payload_sha256") || data.key?("effect") || (data.key?("message") && data.dig("message", "kind") != "question")
48
+ raise InvalidEnvelope, "OTP envelope cannot contain payload digest, folder answer or effect"
49
+ end
50
+ else
51
+ raise InvalidEnvelope, "invalid non-secret payload digest" unless data["payload_sha256"].is_a?(String) && DIGEST.match?(data["payload_sha256"])
52
+ end
53
+ message!(data["message"], data) if data.key?("message")
54
+ effect!(data["effect"]) if data.key?("effect")
55
+ expected.each do |key, wanted|
56
+ raise InvalidEnvelope, "managed #{key} does not match authoritative binding" unless data[key.to_s] == wanted
57
+ end
58
+ data
59
+ rescue JSON::ParserError, JSON::GeneratorError, TypeError => e
60
+ raise InvalidEnvelope, "invalid managed envelope JSON (#{e.class})"
61
+ end
62
+
63
+ def self.reverse!(value)
64
+ return if value.nil? # Explicit pane-less consume, never a fallback target.
65
+ unless value.is_a?(Hash) && value.keys.sort == %w[pane schema session] && value["schema"] == Providers::Ref::SCHEMA
66
+ raise InvalidEnvelope, "invalid reverse address schema"
67
+ end
68
+ Providers::Ref.new(session: value["session"], pane: value["pane"], canonical: true,
69
+ session_source: "reverse.session", pane_source: "reverse.pane")
70
+ rescue Providers::InvalidRefError => e
71
+ raise InvalidEnvelope, e.message
72
+ end
73
+
74
+ def self.message!(value, envelope)
75
+ unless value.is_a?(Hash) && value["schema"] == MESSAGE_SCHEMA && value["id"] == envelope["correlation_id"]
76
+ raise InvalidEnvelope, "folder message correlation/version differs"
77
+ end
78
+ kind = value["kind"]
79
+ body_key, time_key = {"question" => %w[question created_at], "answer" => %w[answer received_at]}[kind]
80
+ raise InvalidEnvelope, "unsupported folder message kind" unless body_key
81
+ unless value.keys.sort == ["schema", "id", "kind", "sender", body_key, time_key].sort
82
+ raise InvalidEnvelope, "invalid folder message fields"
83
+ end
84
+ token!(value["sender"], "message.sender")
85
+ body = value[body_key]
86
+ unless body.is_a?(String) && body.valid_encoding? && !body.strip.empty? && !body.include?("\0")
87
+ raise InvalidEnvelope, "invalid folder message body"
88
+ end
89
+ # Conservative secret gate shared with the ordinary folder boundary.
90
+ if kind == "answer" && SecretGate::PATTERN.match?(body)
91
+ raise InvalidEnvelope, "secret-bearing folder answers are prohibited"
92
+ end
93
+ if envelope.key?("payload_sha256") && envelope["payload_sha256"] != Digest::SHA256.hexdigest(body)
94
+ raise InvalidEnvelope, "folder payload digest differs"
95
+ end
96
+ unless value[time_key].is_a?(String) && value[time_key].match?(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\z/)
97
+ raise InvalidEnvelope, "invalid folder message timestamp"
98
+ end
99
+ begin
100
+ parsed = Time.iso8601(value[time_key])
101
+ unless parsed.utc.strftime("%Y-%m-%dT%H:%M:%SZ") == value[time_key]
102
+ raise ArgumentError, "non-canonical calendar components"
103
+ end
104
+ rescue ArgumentError
105
+ raise InvalidEnvelope, "invalid folder message timestamp"
106
+ end
107
+ end
108
+
109
+ def self.effect!(value)
110
+ unless value.is_a?(Hash) && (value.keys - %w[authorization_ref receipt_ref]).empty? && value.key?("authorization_ref")
111
+ raise InvalidEnvelope, "effect requires an authorization reference"
112
+ end
113
+ value.each { |key, item| token!(item, "effect.#{key}") }
114
+ end
115
+
116
+ def self.token!(value, label)
117
+ raise InvalidEnvelope, "invalid #{label}" unless value.is_a?(String) && TOKEN.match?(value)
118
+ end
119
+ private_class_method :reverse!, :message!, :effect!, :token!
120
+ end
121
+ end
122
+ end
123
+ end
@@ -5,10 +5,10 @@ require_relative "errors"
5
5
  module Ace
6
6
  module Hitl
7
7
  module Providers
8
- # Typed reverse address of the asking agent: herdr session + pane
9
- # (spec 8wm.t.vrz §3). Versioned schema ace.hitl.ref/v1; captured
10
- # fail-closed from the herdr environment at ask time. ace-herdr
11
- # exports both variables when it bootstraps an agent pane.
8
+ # Typed reverse address of the asking agent: Herdr tokens or the exact
9
+ # paired native tmux IDs. Syntax never supplies native ownership authority.
10
+ # Versioned schema ace.hitl.ref/v1; ace-herdr exports the environment
11
+ # variables when it bootstraps an agent pane.
12
12
  #
13
13
  # The ref identifies WHERE the answer goes; it never carries answer
14
14
  # content.
@@ -18,43 +18,56 @@ module Ace
18
18
  PANE_ENV = "HERDR_PANE"
19
19
  MAX_LENGTH = 128
20
20
  TOKEN_PATTERN = /\A[A-Za-z0-9._:-]+\z/.freeze
21
+ SESSION_ID_PATTERN = /\A\$(?:0|[1-9][0-9]*)\z/.freeze
22
+ PANE_ID_PATTERN = /\A%(?:0|[1-9][0-9]*)\z/.freeze
21
23
 
22
24
  attr_reader :session, :pane
23
25
 
24
- def initialize(session:, pane:)
25
- @session = session
26
- @pane = pane
26
+ def initialize(session:, pane:, canonical: false, session_source: "session", pane_source: "pane")
27
+ @session = validated_component(session, session_source, SESSION_ID_PATTERN, canonical)
28
+ @pane = validated_component(pane, pane_source, PANE_ID_PATTERN, canonical)
29
+ unless SESSION_ID_PATTERN.match?(@session) == PANE_ID_PATTERN.match?(@pane)
30
+ raise InvalidRefError, "#{session_source}/#{pane_source} require a paired native tmux session and pane"
31
+ end
27
32
  end
28
33
 
29
34
  # Fail closed: raises InvalidRefError naming the offending variable
30
35
  # and rule when the reverse address is absent or invalid.
31
36
  def self.from_env(env = ENV)
32
- session = validate!(env[SESSION_ENV], SESSION_ENV)
33
- pane = validate!(env[PANE_ENV], PANE_ENV)
34
- new(session: session, pane: pane)
37
+ new(session: env[SESSION_ENV], pane: env[PANE_ENV],
38
+ session_source: SESSION_ENV, pane_source: PANE_ENV)
39
+ end
40
+
41
+ def to_h
42
+ {schema: SCHEMA, session: session, pane: pane}
35
43
  end
36
44
 
37
- def self.validate!(value, source)
38
- token = value.to_s.strip
45
+ private
46
+
47
+ def validated_component(value, source, native_pattern, canonical)
48
+ raise InvalidRefError, "#{source} is required (reverse address, #{SCHEMA})" if value.nil?
49
+ unless value.is_a?(String) && value.valid_encoding? && value.encoding.ascii_compatible?
50
+ raise InvalidRefError, "#{source} must be an ASCII-compatible encoded String"
51
+ end
52
+ token = value.strip
39
53
  if token.empty?
40
54
  raise InvalidRefError,
41
- "#{source} is required (herdr reverse address, #{SCHEMA})"
55
+ "#{source} is required (reverse address, #{SCHEMA})"
42
56
  end
43
57
  if token.length > MAX_LENGTH
44
58
  raise InvalidRefError,
45
59
  "#{source} exceeds #{MAX_LENGTH} characters"
46
60
  end
47
- unless token.match?(TOKEN_PATTERN)
61
+ if canonical && token != value
62
+ raise InvalidRefError, "#{source} must be a canonical reverse address component"
63
+ end
64
+ unless token.match?(TOKEN_PATTERN) || token.match?(native_pattern)
48
65
  raise InvalidRefError,
49
66
  "#{source} contains invalid characters " \
50
- "(allowed: letters, digits, '.', '_', ':', '-')"
67
+ "(expected a Herdr token or a canonical native ID for this field)"
51
68
  end
52
69
 
53
- token
54
- end
55
-
56
- def to_h
57
- {schema: SCHEMA, session: session, pane: pane}
70
+ token.freeze
58
71
  end
59
72
  end
60
73
  end
@@ -4,7 +4,7 @@ module Ace
4
4
  module Hitl
5
5
  module Providers
6
6
  # ask(question:, ...) result: local event id + relay request id.
7
- AskResult = Struct.new(:event_id, :request_id, keyword_init: true)
7
+ AskResult = Struct.new(:event_id, :request_id, :ref, keyword_init: true)
8
8
 
9
9
  # deliver(ref, answer) result; state is :delivered, :retryable
10
10
  # (safe to re-push identical content) or :failed.
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Hitl
5
+ module Contract
6
+ # Shared conservative ordinary-answer gate. Protected OTP IPC is the
7
+ # only boundary that accepts these bytes; they never enter an envelope.
8
+ module SecretGate
9
+ PATTERN = Regexp.new(
10
+ "(?:github_pat_[A-Za-z0-9_]+|gh[pousr]_[A-Za-z0-9]+|" \
11
+ "sk-[A-Za-z0-9_-]{20,}|-----BEGIN [A-Z ]+PRIVATE KEY-----|" \
12
+ "\\b(?:otp|token|secret|password)\\s*[:=]\\s*\\S+|" \
13
+ "\\b[0-9]{6}\\b)", Regexp::IGNORECASE
14
+ ).freeze
15
+ end
16
+ end
17
+ end
18
+ end
@@ -3,7 +3,7 @@
3
3
  module Ace
4
4
  module Hitl
5
5
  module Contract
6
- VERSION = "0.1.0"
6
+ VERSION = "0.2.0"
7
7
  end
8
8
  end
9
9
  end
@@ -4,3 +4,4 @@ require_relative "contract/version"
4
4
  require_relative "contract/errors"
5
5
  require_relative "contract/ref"
6
6
  require_relative "contract/results"
7
+ require_relative "contract/managed_envelope"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-hitl-contract
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
@@ -64,8 +64,15 @@ files:
64
64
  - Rakefile
65
65
  - lib/ace/hitl/contract.rb
66
66
  - lib/ace/hitl/contract/errors.rb
67
+ - lib/ace/hitl/contract/examples/ingress-checkpoints.json
68
+ - lib/ace/hitl/contract/examples/managed-answer.json
69
+ - lib/ace/hitl/contract/examples/managed-otp-request.json
70
+ - lib/ace/hitl/contract/examples/managed-request.json
71
+ - lib/ace/hitl/contract/managed.v1.schema.json
72
+ - lib/ace/hitl/contract/managed_envelope.rb
67
73
  - lib/ace/hitl/contract/ref.rb
68
74
  - lib/ace/hitl/contract/results.rb
75
+ - lib/ace/hitl/contract/secret_gate.rb
69
76
  - lib/ace/hitl/contract/version.rb
70
77
  homepage: https://github.com/cs3b/ace
71
78
  licenses: