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 +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +41 -0
- data/lib/ace/hitl/contract/examples/ingress-checkpoints.json +50 -0
- data/lib/ace/hitl/contract/examples/managed-answer.json +25 -0
- data/lib/ace/hitl/contract/examples/managed-otp-request.json +20 -0
- data/lib/ace/hitl/contract/examples/managed-request.json +25 -0
- data/lib/ace/hitl/contract/managed.v1.schema.json +262 -0
- data/lib/ace/hitl/contract/managed_envelope.rb +123 -0
- data/lib/ace/hitl/contract/ref.rb +33 -20
- data/lib/ace/hitl/contract/results.rb +1 -1
- data/lib/ace/hitl/contract/secret_gate.rb +18 -0
- data/lib/ace/hitl/contract/version.rb +1 -1
- data/lib/ace/hitl/contract.rb +1 -0
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 759e3f37e3bd513e374fbcd315d630178d48978c6e8b07da31d0b94b4a16d7e4
|
|
4
|
+
data.tar.gz: 4568504b1fc12a0e39e36b7f440e523588cd758d7eb0f750fa6ff4226d2008b5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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:
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
"(
|
|
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
|
data/lib/ace/hitl/contract.rb
CHANGED
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.
|
|
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:
|