apsimo-hostworker 0.2.0__py3-none-any.whl
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.
- apsimo_hostworker/__init__.py +134 -0
- apsimo_hostworker/_private_io.py +267 -0
- apsimo_hostworker/admission.py +297 -0
- apsimo_hostworker/catalog.py +754 -0
- apsimo_hostworker/client.py +292 -0
- apsimo_hostworker/compat.py +53 -0
- apsimo_hostworker/conformance/__init__.py +52 -0
- apsimo_hostworker/conformance/__main__.py +29 -0
- apsimo_hostworker/conformance/harness.py +278 -0
- apsimo_hostworker/conformance/suite.py +1109 -0
- apsimo_hostworker/contract.py +402 -0
- apsimo_hostworker/gate.py +446 -0
- apsimo_hostworker/intent.py +221 -0
- apsimo_hostworker/sqlite_store.py +1815 -0
- apsimo_hostworker/store.py +446 -0
- apsimo_hostworker/worker.py +643 -0
- apsimo_hostworker-0.2.0.dist-info/METADATA +24 -0
- apsimo_hostworker-0.2.0.dist-info/RECORD +22 -0
- apsimo_hostworker-0.2.0.dist-info/WHEEL +5 -0
- apsimo_hostworker-0.2.0.dist-info/licenses/LICENSE +21 -0
- apsimo_hostworker-0.2.0.dist-info/top_level.txt +2 -0
- colony_hostworker/__init__.py +4 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Stdlib-only core for governed Colony host workers.
|
|
2
|
+
|
|
3
|
+
Phase A of the host-worker extraction: the wire contract, the governed tool
|
|
4
|
+
catalog, ``HermesToolActionIntentV1`` validation, and the approval-gate
|
|
5
|
+
invariant core. Phase B: the loopback execution client, the dispatch
|
|
6
|
+
admission, the :class:`~apsimo_hostworker.store.ActionStore` protocol with
|
|
7
|
+
its documented transactional invariants, the reference SQLite store, the
|
|
8
|
+
one-mutation worker, and the executable store conformance suite
|
|
9
|
+
(:mod:`apsimo_hostworker.conformance`). This distribution must never
|
|
10
|
+
import FastAPI or ``apsimo`` — see the design rule in
|
|
11
|
+
:mod:`apsimo_hostworker.contract`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from .admission import (
|
|
17
|
+
DispatchAdmission,
|
|
18
|
+
DispatchAdmissionError,
|
|
19
|
+
FileDispatchAdmission,
|
|
20
|
+
sqlite_database_identity,
|
|
21
|
+
)
|
|
22
|
+
from .catalog import (
|
|
23
|
+
ACTION_TOOL_NAMES,
|
|
24
|
+
GRANT_AUTHORIZABLE_TOOL_NAMES,
|
|
25
|
+
NON_GRANTABLE_TOOL_NAMES,
|
|
26
|
+
TOOL_CATALOG,
|
|
27
|
+
ToolCatalogError,
|
|
28
|
+
ToolSpec,
|
|
29
|
+
validate_tool_args,
|
|
30
|
+
)
|
|
31
|
+
from .client import (
|
|
32
|
+
ClientCredential,
|
|
33
|
+
GovernedActionClient,
|
|
34
|
+
GovernedActionClientError,
|
|
35
|
+
WORKER_PRINCIPAL,
|
|
36
|
+
build_no_redirect_opener,
|
|
37
|
+
)
|
|
38
|
+
from .contract import (
|
|
39
|
+
GovernedContractError,
|
|
40
|
+
canonical_json_ascii,
|
|
41
|
+
canonical_json_utf8,
|
|
42
|
+
sha256_json_ascii,
|
|
43
|
+
sha256_json_utf8,
|
|
44
|
+
)
|
|
45
|
+
from .gate import (
|
|
46
|
+
BOUNDED_GRANT_SHAPE,
|
|
47
|
+
DEFAULT_REGISTRY,
|
|
48
|
+
GRANT_BINDING_METHOD,
|
|
49
|
+
GRANT_UNLIMITED_SENTINEL,
|
|
50
|
+
GateAuthorization,
|
|
51
|
+
MESSAGE_DELIVERY_SHAPE,
|
|
52
|
+
OwnerGateError,
|
|
53
|
+
ProvenanceShape,
|
|
54
|
+
ProvenanceShapeError,
|
|
55
|
+
ProvenanceShapeRegistry,
|
|
56
|
+
assert_dispatchable,
|
|
57
|
+
default_registry,
|
|
58
|
+
validate_owner_gate,
|
|
59
|
+
)
|
|
60
|
+
from .intent import HermesActionIntentError, HermesToolActionIntentV1
|
|
61
|
+
from .sqlite_store import SqliteActionStore
|
|
62
|
+
from .store import (
|
|
63
|
+
ActionIdempotencyConflict,
|
|
64
|
+
ActionLeaseConflict,
|
|
65
|
+
ActionNotFound,
|
|
66
|
+
ActionStore,
|
|
67
|
+
ActionStoreError,
|
|
68
|
+
ActionTransitionError,
|
|
69
|
+
)
|
|
70
|
+
from .worker import (
|
|
71
|
+
GovernedActionWorker,
|
|
72
|
+
GovernedActionWorkerError,
|
|
73
|
+
build_execution_request,
|
|
74
|
+
validate_execution_result,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
try: # single source of truth: the installed distribution metadata
|
|
78
|
+
from importlib.metadata import PackageNotFoundError, version as _dist_version
|
|
79
|
+
try:
|
|
80
|
+
__version__ = _dist_version("apsimo-hostworker")
|
|
81
|
+
except PackageNotFoundError: # running from a source tree, not installed
|
|
82
|
+
__version__ = "0.2.0"
|
|
83
|
+
except ImportError: # pragma: no cover
|
|
84
|
+
__version__ = "0.2.0"
|
|
85
|
+
|
|
86
|
+
__all__ = (
|
|
87
|
+
"ACTION_TOOL_NAMES",
|
|
88
|
+
"ActionIdempotencyConflict",
|
|
89
|
+
"ActionLeaseConflict",
|
|
90
|
+
"ActionNotFound",
|
|
91
|
+
"ActionStore",
|
|
92
|
+
"ActionStoreError",
|
|
93
|
+
"ActionTransitionError",
|
|
94
|
+
"BOUNDED_GRANT_SHAPE",
|
|
95
|
+
"ClientCredential",
|
|
96
|
+
"DEFAULT_REGISTRY",
|
|
97
|
+
"DispatchAdmission",
|
|
98
|
+
"DispatchAdmissionError",
|
|
99
|
+
"FileDispatchAdmission",
|
|
100
|
+
"GRANT_AUTHORIZABLE_TOOL_NAMES",
|
|
101
|
+
"GRANT_BINDING_METHOD",
|
|
102
|
+
"GRANT_UNLIMITED_SENTINEL",
|
|
103
|
+
"GateAuthorization",
|
|
104
|
+
"GovernedActionClient",
|
|
105
|
+
"GovernedActionClientError",
|
|
106
|
+
"GovernedActionWorker",
|
|
107
|
+
"GovernedActionWorkerError",
|
|
108
|
+
"GovernedContractError",
|
|
109
|
+
"HermesActionIntentError",
|
|
110
|
+
"HermesToolActionIntentV1",
|
|
111
|
+
"MESSAGE_DELIVERY_SHAPE",
|
|
112
|
+
"NON_GRANTABLE_TOOL_NAMES",
|
|
113
|
+
"OwnerGateError",
|
|
114
|
+
"ProvenanceShape",
|
|
115
|
+
"ProvenanceShapeError",
|
|
116
|
+
"ProvenanceShapeRegistry",
|
|
117
|
+
"SqliteActionStore",
|
|
118
|
+
"TOOL_CATALOG",
|
|
119
|
+
"ToolCatalogError",
|
|
120
|
+
"ToolSpec",
|
|
121
|
+
"WORKER_PRINCIPAL",
|
|
122
|
+
"assert_dispatchable",
|
|
123
|
+
"build_execution_request",
|
|
124
|
+
"build_no_redirect_opener",
|
|
125
|
+
"canonical_json_ascii",
|
|
126
|
+
"canonical_json_utf8",
|
|
127
|
+
"default_registry",
|
|
128
|
+
"sha256_json_ascii",
|
|
129
|
+
"sha256_json_utf8",
|
|
130
|
+
"sqlite_database_identity",
|
|
131
|
+
"validate_execution_result",
|
|
132
|
+
"validate_owner_gate",
|
|
133
|
+
"validate_tool_args",
|
|
134
|
+
)
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Owner-only file and loopback-origin primitives for host workers.
|
|
2
|
+
|
|
3
|
+
Everything here is stdlib-only, side-effect-free beyond reading the named
|
|
4
|
+
file, and deliberately paranoid: these primitives sit under the credential
|
|
5
|
+
loader (:mod:`apsimo_hostworker.client`) and the dispatch-admission check
|
|
6
|
+
(:mod:`apsimo_hostworker.admission`), where a symlink race, a
|
|
7
|
+
group-readable secret, or an over-large document must fail closed rather
|
|
8
|
+
than degrade.
|
|
9
|
+
|
|
10
|
+
The bounded JSON reader is also the only JSON decoder the client uses for
|
|
11
|
+
network responses: it refuses duplicate object keys, non-finite numbers,
|
|
12
|
+
oversized integers, control characters, and unbounded nesting before any
|
|
13
|
+
value reaches a validator.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import ipaddress
|
|
19
|
+
import json
|
|
20
|
+
import math
|
|
21
|
+
import os
|
|
22
|
+
import stat
|
|
23
|
+
import urllib.parse
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, Mapping
|
|
26
|
+
|
|
27
|
+
PRIVATE_DOCUMENT_MAX_BYTES = 16 * 1024
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PrivateIOError(RuntimeError):
|
|
31
|
+
"""An owner-only file or origin failed its safety contract."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def strict_json_bytes(raw: bytes, *, maximum: int, error=PrivateIOError) -> Any:
|
|
35
|
+
"""Decode one bounded finite JSON value without duplicate object keys."""
|
|
36
|
+
|
|
37
|
+
if not isinstance(raw, bytes) or len(raw) > maximum:
|
|
38
|
+
raise error("JSON document exceeds its safety bound")
|
|
39
|
+
|
|
40
|
+
def pairs(values):
|
|
41
|
+
result = {}
|
|
42
|
+
for key, value in values:
|
|
43
|
+
if key in result:
|
|
44
|
+
raise error("JSON object keys must be unique")
|
|
45
|
+
result[key] = value
|
|
46
|
+
return result
|
|
47
|
+
|
|
48
|
+
def constant(_value):
|
|
49
|
+
raise error("JSON numbers must be finite")
|
|
50
|
+
|
|
51
|
+
def integer(value):
|
|
52
|
+
if len(value.lstrip("-")) > 19:
|
|
53
|
+
raise error("JSON integer exceeds its safety bound")
|
|
54
|
+
parsed = int(value)
|
|
55
|
+
if abs(parsed) > (1 << 63) - 1:
|
|
56
|
+
raise error("JSON integer exceeds its safety bound")
|
|
57
|
+
return parsed
|
|
58
|
+
|
|
59
|
+
def floating(value):
|
|
60
|
+
parsed = float(value)
|
|
61
|
+
if not math.isfinite(parsed):
|
|
62
|
+
raise error("JSON numbers must be finite")
|
|
63
|
+
return parsed
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
value = json.loads(
|
|
67
|
+
raw.decode("utf-8", errors="strict"),
|
|
68
|
+
object_pairs_hook=pairs,
|
|
69
|
+
parse_constant=constant,
|
|
70
|
+
parse_int=integer,
|
|
71
|
+
parse_float=floating,
|
|
72
|
+
)
|
|
73
|
+
except error:
|
|
74
|
+
raise
|
|
75
|
+
except (UnicodeError, ValueError, TypeError, OverflowError, RecursionError) as exc:
|
|
76
|
+
raise error("JSON document is malformed") from exc
|
|
77
|
+
|
|
78
|
+
stack = [(value, 0)]
|
|
79
|
+
count = 0
|
|
80
|
+
while stack:
|
|
81
|
+
item, depth = stack.pop()
|
|
82
|
+
count += 1
|
|
83
|
+
if count > 1024 or depth > 16:
|
|
84
|
+
raise error("JSON document is too complex")
|
|
85
|
+
if isinstance(item, str):
|
|
86
|
+
if len(item) > 8192 or any(
|
|
87
|
+
ord(character) < 0x20 or ord(character) == 0x7F
|
|
88
|
+
for character in item
|
|
89
|
+
):
|
|
90
|
+
raise error("JSON text is unsafe")
|
|
91
|
+
try:
|
|
92
|
+
item.encode("utf-8")
|
|
93
|
+
except UnicodeError as exc:
|
|
94
|
+
raise error("JSON text is not UTF-8") from exc
|
|
95
|
+
elif isinstance(item, Mapping):
|
|
96
|
+
stack.extend((key, depth + 1) for key in item)
|
|
97
|
+
stack.extend((child, depth + 1) for child in item.values())
|
|
98
|
+
elif isinstance(item, list):
|
|
99
|
+
stack.extend((child, depth + 1) for child in item)
|
|
100
|
+
elif item is not None and not isinstance(item, (bool, int, float)):
|
|
101
|
+
raise error("JSON document contains an unsafe value")
|
|
102
|
+
return value
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def safe_ancestry(
|
|
106
|
+
path: Path, *, label: str, error=PrivateIOError
|
|
107
|
+
) -> tuple[tuple[int, int, int], ...]:
|
|
108
|
+
"""Attest every existing ancestor without resolving through a symlink."""
|
|
109
|
+
|
|
110
|
+
chain = []
|
|
111
|
+
cursor = path
|
|
112
|
+
while True:
|
|
113
|
+
chain.append(cursor)
|
|
114
|
+
if cursor.parent == cursor:
|
|
115
|
+
break
|
|
116
|
+
cursor = cursor.parent
|
|
117
|
+
identities = []
|
|
118
|
+
try:
|
|
119
|
+
for candidate in reversed(chain):
|
|
120
|
+
info = candidate.lstat()
|
|
121
|
+
if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode):
|
|
122
|
+
raise error("%s path ancestry is unsafe" % label)
|
|
123
|
+
identities.append((info.st_dev, info.st_ino, info.st_mode))
|
|
124
|
+
except error:
|
|
125
|
+
raise
|
|
126
|
+
except OSError as exc:
|
|
127
|
+
raise error("%s path ancestry is unavailable" % label) from exc
|
|
128
|
+
return tuple(identities)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def read_private_json(
|
|
132
|
+
path: str, *, label: str, error=PrivateIOError
|
|
133
|
+
) -> tuple[Path, bytes, Any]:
|
|
134
|
+
"""Read an owner-only mode-0600 regular file without racing a symlink.
|
|
135
|
+
|
|
136
|
+
The file must be a regular file owned by the calling user with mode
|
|
137
|
+
exactly ``0600``, at most :data:`PRIVATE_DOCUMENT_MAX_BYTES` long, whose
|
|
138
|
+
identity (device, inode, size, mtime) and whole directory ancestry are
|
|
139
|
+
unchanged across the read. Anything else fails closed.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
configured = str(path or "").strip()
|
|
143
|
+
if not configured:
|
|
144
|
+
raise error("%s file is not configured" % label)
|
|
145
|
+
target = Path(os.path.abspath(os.path.expanduser(configured)))
|
|
146
|
+
if not hasattr(os, "O_NOFOLLOW") or not hasattr(os, "O_CLOEXEC"):
|
|
147
|
+
raise error("private file loading is unsupported")
|
|
148
|
+
descriptor = None
|
|
149
|
+
try:
|
|
150
|
+
ancestry_before = safe_ancestry(target.parent, label=label, error=error)
|
|
151
|
+
initial = target.lstat()
|
|
152
|
+
if stat.S_ISLNK(initial.st_mode):
|
|
153
|
+
raise error("%s must not be a symlink" % label)
|
|
154
|
+
descriptor = os.open(target, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
|
|
155
|
+
before = os.fstat(descriptor)
|
|
156
|
+
if (
|
|
157
|
+
not stat.S_ISREG(before.st_mode)
|
|
158
|
+
or stat.S_IMODE(before.st_mode) != 0o600
|
|
159
|
+
or (hasattr(os, "geteuid") and before.st_uid != os.geteuid())
|
|
160
|
+
or before.st_size < 0
|
|
161
|
+
or before.st_size > PRIVATE_DOCUMENT_MAX_BYTES
|
|
162
|
+
or (initial.st_dev, initial.st_ino) != (before.st_dev, before.st_ino)
|
|
163
|
+
):
|
|
164
|
+
raise error("%s must be an owned mode-0600 regular file" % label)
|
|
165
|
+
collected = bytearray()
|
|
166
|
+
while len(collected) <= PRIVATE_DOCUMENT_MAX_BYTES:
|
|
167
|
+
chunk = os.read(
|
|
168
|
+
descriptor,
|
|
169
|
+
min(4096, PRIVATE_DOCUMENT_MAX_BYTES + 1 - len(collected)),
|
|
170
|
+
)
|
|
171
|
+
if not chunk:
|
|
172
|
+
break
|
|
173
|
+
collected.extend(chunk)
|
|
174
|
+
after = os.fstat(descriptor)
|
|
175
|
+
current = target.lstat()
|
|
176
|
+
ancestry_after = safe_ancestry(target.parent, label=label, error=error)
|
|
177
|
+
stable = (
|
|
178
|
+
before.st_dev,
|
|
179
|
+
before.st_ino,
|
|
180
|
+
before.st_uid,
|
|
181
|
+
before.st_mode,
|
|
182
|
+
before.st_size,
|
|
183
|
+
before.st_mtime_ns,
|
|
184
|
+
) == (
|
|
185
|
+
after.st_dev,
|
|
186
|
+
after.st_ino,
|
|
187
|
+
after.st_uid,
|
|
188
|
+
after.st_mode,
|
|
189
|
+
after.st_size,
|
|
190
|
+
after.st_mtime_ns,
|
|
191
|
+
)
|
|
192
|
+
if (
|
|
193
|
+
len(collected) > PRIVATE_DOCUMENT_MAX_BYTES
|
|
194
|
+
or len(collected) != before.st_size
|
|
195
|
+
or not stable
|
|
196
|
+
or ancestry_before != ancestry_after
|
|
197
|
+
or (current.st_dev, current.st_ino) != (before.st_dev, before.st_ino)
|
|
198
|
+
):
|
|
199
|
+
raise error("%s changed while being read" % label)
|
|
200
|
+
raw = bytes(collected)
|
|
201
|
+
except error:
|
|
202
|
+
raise
|
|
203
|
+
except OSError as exc:
|
|
204
|
+
raise error("%s file is unavailable" % label) from exc
|
|
205
|
+
finally:
|
|
206
|
+
if descriptor is not None:
|
|
207
|
+
os.close(descriptor)
|
|
208
|
+
return (
|
|
209
|
+
target,
|
|
210
|
+
raw,
|
|
211
|
+
strict_json_bytes(raw, maximum=PRIVATE_DOCUMENT_MAX_BYTES, error=error),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def loopback_origin(value: str, *, error=PrivateIOError) -> str:
|
|
216
|
+
"""Return ``value`` iff it is one canonical loopback HTTP(S) origin.
|
|
217
|
+
|
|
218
|
+
Exactly ``scheme://host[:port]`` with a loopback host: no path, query,
|
|
219
|
+
fragment, userinfo, whitespace, or non-printable bytes. Everything a
|
|
220
|
+
governed-action client or admission may talk to must pass this, so a
|
|
221
|
+
configuration mistake can never point owner-authorized mutations at a
|
|
222
|
+
remote host.
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
raw = str(value or "")
|
|
226
|
+
if (
|
|
227
|
+
not raw
|
|
228
|
+
or raw != raw.strip()
|
|
229
|
+
or any(ord(character) < 0x21 or ord(character) > 0x7E for character in raw)
|
|
230
|
+
):
|
|
231
|
+
raise error("service origin is invalid")
|
|
232
|
+
try:
|
|
233
|
+
parsed = urllib.parse.urlsplit(raw)
|
|
234
|
+
port = parsed.port
|
|
235
|
+
except ValueError as exc:
|
|
236
|
+
raise error("service origin is invalid") from exc
|
|
237
|
+
if (
|
|
238
|
+
parsed.scheme not in ("http", "https")
|
|
239
|
+
or not parsed.hostname
|
|
240
|
+
or parsed.username is not None
|
|
241
|
+
or parsed.password is not None
|
|
242
|
+
or parsed.path
|
|
243
|
+
or parsed.query
|
|
244
|
+
or parsed.fragment
|
|
245
|
+
or not parsed.netloc
|
|
246
|
+
):
|
|
247
|
+
raise error("service origin must be one loopback origin")
|
|
248
|
+
hostname = parsed.hostname.lower()
|
|
249
|
+
try:
|
|
250
|
+
loopback = ipaddress.ip_address(hostname).is_loopback
|
|
251
|
+
except ValueError:
|
|
252
|
+
loopback = hostname == "localhost"
|
|
253
|
+
if not loopback or (port is not None and not 1 <= port <= 65535):
|
|
254
|
+
raise error("service origin must be loopback")
|
|
255
|
+
if urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, "", "", "")) != raw:
|
|
256
|
+
raise error("service origin must be canonical")
|
|
257
|
+
return raw
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
__all__ = (
|
|
261
|
+
"PRIVATE_DOCUMENT_MAX_BYTES",
|
|
262
|
+
"PrivateIOError",
|
|
263
|
+
"loopback_origin",
|
|
264
|
+
"read_private_json",
|
|
265
|
+
"safe_ancestry",
|
|
266
|
+
"strict_json_bytes",
|
|
267
|
+
)
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Dispatch admission: an operator kill-switch checked before every mutation.
|
|
2
|
+
|
|
3
|
+
THE PROTOCOL
|
|
4
|
+
============
|
|
5
|
+
A :class:`DispatchAdmission` answers exactly one question at exactly one
|
|
6
|
+
moment: "may this process attempt a mutation RIGHT NOW?" The worker calls
|
|
7
|
+
``assert_live()`` immediately before every owner-authorized dispatch (the
|
|
8
|
+
transition that consumes the approval gate and precedes the one PUT).
|
|
9
|
+
``assert_live()`` MUST:
|
|
10
|
+
|
|
11
|
+
* re-read its authority from durable or ambient state at call time — never
|
|
12
|
+
cache a yes;
|
|
13
|
+
* raise :class:`DispatchAdmissionError` to refuse, in which case the worker
|
|
14
|
+
defers the leased action WITHOUT consuming the gate or counting an
|
|
15
|
+
attempt; and
|
|
16
|
+
* return ``None`` only when a mutation is admitted for immediate dispatch.
|
|
17
|
+
|
|
18
|
+
Admission is deliberately NOT authorization. The owner approval gate binds
|
|
19
|
+
one action; admission binds the deployment (this binary, this store, this
|
|
20
|
+
endpoint, these tools, this time window). Deleting or expiring the
|
|
21
|
+
admission halts all mutations without touching any durable action state.
|
|
22
|
+
|
|
23
|
+
THE REFERENCE IMPLEMENTATION
|
|
24
|
+
============================
|
|
25
|
+
:class:`FileDispatchAdmission` reads an owner-only mode-0600 canonical-JSON
|
|
26
|
+
file on every check. The private deployment this generalizes pinned its
|
|
27
|
+
SQLite store by device/inode and its release by commit SHA inside the
|
|
28
|
+
admission document; those are host-deployment details, so here they become
|
|
29
|
+
one optional ``identity_probe`` hook: a host callable returning any
|
|
30
|
+
JSON-serializable identity document (a device/inode pair, a release SHA, a
|
|
31
|
+
container digest, ...). The probe result is captured at construction,
|
|
32
|
+
required to match the admission file's ``binding_identity`` field, and
|
|
33
|
+
re-probed on EVERY ``assert_live()`` — so the resource the admission was
|
|
34
|
+
issued for cannot be swapped underneath a live worker. A host that
|
|
35
|
+
configures no probe gets a file whose ``binding_identity`` must be ``null``;
|
|
36
|
+
the probe is optional, the field is not.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import math
|
|
42
|
+
import os
|
|
43
|
+
import re
|
|
44
|
+
import stat
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
from typing import Any, Callable, Iterable, Mapping, Protocol, runtime_checkable
|
|
47
|
+
|
|
48
|
+
from ._private_io import loopback_origin, read_private_json, safe_ancestry
|
|
49
|
+
from .catalog import ACTION_TOOL_NAMES
|
|
50
|
+
from .contract import GATE_CLOCK_SKEW_SECONDS, canonical_json_utf8
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class DispatchAdmissionError(RuntimeError):
|
|
54
|
+
"""The deployment is not admitted to attempt a mutation right now."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@runtime_checkable
|
|
58
|
+
class DispatchAdmission(Protocol):
|
|
59
|
+
"""See the module docstring for the full ``assert_live`` contract."""
|
|
60
|
+
|
|
61
|
+
def assert_live(self) -> None:
|
|
62
|
+
"""Raise :class:`DispatchAdmissionError` unless a mutation may be
|
|
63
|
+
attempted immediately; never cache a previous answer."""
|
|
64
|
+
...
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
ADMISSION_SCHEMA = "ColonyHostWorkerAdmissionV1"
|
|
68
|
+
ADMISSION_FIELDS = frozenset(
|
|
69
|
+
{
|
|
70
|
+
"schema",
|
|
71
|
+
"version",
|
|
72
|
+
"authorized",
|
|
73
|
+
"authorization_id",
|
|
74
|
+
"colony_origin",
|
|
75
|
+
"enabled_tools",
|
|
76
|
+
"binding_identity",
|
|
77
|
+
"created_at",
|
|
78
|
+
"expires_at",
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
ADMISSION_MAX_LIFETIME_SECONDS = 30 * 24 * 60 * 60
|
|
82
|
+
|
|
83
|
+
AUTHORIZATION_ID_RE = re.compile(r"^[0-9a-f]{32}$")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def sqlite_database_identity(path: str | Path) -> dict[str, Any]:
|
|
87
|
+
"""Reference ``identity_probe`` for a host whose store is local SQLite.
|
|
88
|
+
|
|
89
|
+
Returns the database file's device/inode identity after asserting that
|
|
90
|
+
the file, its mutable WAL/journal siblings, and its whole directory
|
|
91
|
+
ancestry are private to the calling user and free of symlinks. This is
|
|
92
|
+
the generalized form of the private deployment's store pinning; hosts
|
|
93
|
+
with a different store supply their own probe (or none).
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
target = Path(os.path.abspath(os.path.expanduser(str(path))))
|
|
97
|
+
safe_ancestry(target.parent, label="action store", error=DispatchAdmissionError)
|
|
98
|
+
try:
|
|
99
|
+
info = target.lstat()
|
|
100
|
+
except OSError as error:
|
|
101
|
+
raise DispatchAdmissionError("action store file is unavailable") from error
|
|
102
|
+
if (
|
|
103
|
+
stat.S_ISLNK(info.st_mode)
|
|
104
|
+
or not stat.S_ISREG(info.st_mode)
|
|
105
|
+
or stat.S_IMODE(info.st_mode) & 0o077
|
|
106
|
+
or (hasattr(os, "geteuid") and info.st_uid != os.geteuid())
|
|
107
|
+
):
|
|
108
|
+
raise DispatchAdmissionError("action store file is unsafe")
|
|
109
|
+
for suffix in ("-wal", "-shm", "-journal"):
|
|
110
|
+
sibling = Path(str(target) + suffix)
|
|
111
|
+
if not os.path.lexists(sibling):
|
|
112
|
+
continue
|
|
113
|
+
try:
|
|
114
|
+
sibling_info = sibling.lstat()
|
|
115
|
+
except OSError as error:
|
|
116
|
+
raise DispatchAdmissionError(
|
|
117
|
+
"action store journal is unavailable"
|
|
118
|
+
) from error
|
|
119
|
+
if (
|
|
120
|
+
stat.S_ISLNK(sibling_info.st_mode)
|
|
121
|
+
or not stat.S_ISREG(sibling_info.st_mode)
|
|
122
|
+
or stat.S_IMODE(sibling_info.st_mode) & 0o077
|
|
123
|
+
or (
|
|
124
|
+
hasattr(os, "geteuid")
|
|
125
|
+
and sibling_info.st_uid != os.geteuid()
|
|
126
|
+
)
|
|
127
|
+
):
|
|
128
|
+
raise DispatchAdmissionError("action store journal is unsafe")
|
|
129
|
+
return {"path": str(target), "device": info.st_dev, "inode": info.st_ino}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class FileDispatchAdmission:
|
|
133
|
+
"""File-based reference :class:`DispatchAdmission`.
|
|
134
|
+
|
|
135
|
+
The admission file must be the exact canonical UTF-8 JSON encoding of an
|
|
136
|
+
:data:`ADMISSION_SCHEMA` document plus one trailing newline, mode 0600,
|
|
137
|
+
owned by the calling user. Every field the file carries is pinned
|
|
138
|
+
against this process's configuration; a mismatch anywhere fails closed.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def __init__(
|
|
142
|
+
self,
|
|
143
|
+
path: str,
|
|
144
|
+
*,
|
|
145
|
+
colony_origin: str,
|
|
146
|
+
enabled_tools: Iterable[str],
|
|
147
|
+
clock: Callable[[], float],
|
|
148
|
+
identity_probe: Callable[[], Any] | None = None,
|
|
149
|
+
) -> None:
|
|
150
|
+
configured = str(path or "")
|
|
151
|
+
if not configured or not os.path.isabs(configured):
|
|
152
|
+
raise DispatchAdmissionError("admission path must be absolute")
|
|
153
|
+
try:
|
|
154
|
+
tools = frozenset(enabled_tools)
|
|
155
|
+
except TypeError as error:
|
|
156
|
+
raise DispatchAdmissionError("admitted tools are invalid") from error
|
|
157
|
+
if (
|
|
158
|
+
not tools
|
|
159
|
+
or any(not isinstance(tool, str) for tool in tools)
|
|
160
|
+
or tools - ACTION_TOOL_NAMES
|
|
161
|
+
):
|
|
162
|
+
raise DispatchAdmissionError("admitted tools are invalid")
|
|
163
|
+
if not callable(clock):
|
|
164
|
+
raise DispatchAdmissionError("admission clock is invalid")
|
|
165
|
+
if identity_probe is not None and not callable(identity_probe):
|
|
166
|
+
raise DispatchAdmissionError("admission identity probe is invalid")
|
|
167
|
+
self.path = configured
|
|
168
|
+
self.colony_origin = loopback_origin(
|
|
169
|
+
colony_origin, error=DispatchAdmissionError
|
|
170
|
+
)
|
|
171
|
+
self.enabled_tools = tools
|
|
172
|
+
self.clock = clock
|
|
173
|
+
self.identity_probe = identity_probe
|
|
174
|
+
# Capture the identity ONCE at construction; assert_live() then
|
|
175
|
+
# requires probe-now == probe-at-construction == file value, so the
|
|
176
|
+
# bound resource cannot be swapped underneath a live worker.
|
|
177
|
+
self.pinned_identity = self._probe_identity()
|
|
178
|
+
|
|
179
|
+
def _probe_identity(self) -> str | None:
|
|
180
|
+
if self.identity_probe is None:
|
|
181
|
+
return None
|
|
182
|
+
try:
|
|
183
|
+
observed = self.identity_probe()
|
|
184
|
+
except DispatchAdmissionError:
|
|
185
|
+
raise
|
|
186
|
+
except Exception as error:
|
|
187
|
+
raise DispatchAdmissionError("admission identity probe failed") from error
|
|
188
|
+
try:
|
|
189
|
+
return canonical_json_utf8(observed)
|
|
190
|
+
except Exception as error:
|
|
191
|
+
raise DispatchAdmissionError(
|
|
192
|
+
"admission identity is not canonical JSON"
|
|
193
|
+
) from error
|
|
194
|
+
|
|
195
|
+
def fence(self) -> dict[str, Any]:
|
|
196
|
+
"""Secret-free deployment identity for observability."""
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
"schema": "ColonyHostWorkerAdmissionFenceV1",
|
|
200
|
+
"admission_file": self.path,
|
|
201
|
+
"colony_origin": self.colony_origin,
|
|
202
|
+
"enabled_tools": sorted(self.enabled_tools),
|
|
203
|
+
"binding_identity": self.pinned_identity,
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
def assert_live(self) -> None:
|
|
207
|
+
_target, raw, document = read_private_json(
|
|
208
|
+
self.path,
|
|
209
|
+
label="host worker admission",
|
|
210
|
+
error=DispatchAdmissionError,
|
|
211
|
+
)
|
|
212
|
+
if not isinstance(document, Mapping) or set(document) != ADMISSION_FIELDS:
|
|
213
|
+
raise DispatchAdmissionError("admission fields are invalid")
|
|
214
|
+
try:
|
|
215
|
+
canonical = (canonical_json_utf8(dict(document)) + "\n").encode("utf-8")
|
|
216
|
+
except (
|
|
217
|
+
TypeError,
|
|
218
|
+
ValueError,
|
|
219
|
+
UnicodeError,
|
|
220
|
+
OverflowError,
|
|
221
|
+
RecursionError,
|
|
222
|
+
) as error:
|
|
223
|
+
raise DispatchAdmissionError("admission is not canonical") from error
|
|
224
|
+
if raw != canonical:
|
|
225
|
+
raise DispatchAdmissionError("admission must be canonical JSON")
|
|
226
|
+
version = document.get("version")
|
|
227
|
+
authorized = document.get("authorized")
|
|
228
|
+
created_at = document.get("created_at")
|
|
229
|
+
expires_at = document.get("expires_at")
|
|
230
|
+
admitted_tools = document.get("enabled_tools")
|
|
231
|
+
binding_identity = document.get("binding_identity")
|
|
232
|
+
if (
|
|
233
|
+
document.get("schema") != ADMISSION_SCHEMA
|
|
234
|
+
or isinstance(version, bool)
|
|
235
|
+
or version != 1
|
|
236
|
+
or authorized is not True
|
|
237
|
+
or not isinstance(document.get("authorization_id"), str)
|
|
238
|
+
or not AUTHORIZATION_ID_RE.fullmatch(document["authorization_id"])
|
|
239
|
+
or document.get("colony_origin") != self.colony_origin
|
|
240
|
+
or not isinstance(admitted_tools, list)
|
|
241
|
+
or admitted_tools != sorted(self.enabled_tools)
|
|
242
|
+
or len(admitted_tools) != len(self.enabled_tools)
|
|
243
|
+
or isinstance(created_at, bool)
|
|
244
|
+
or not isinstance(created_at, (int, float))
|
|
245
|
+
or isinstance(expires_at, bool)
|
|
246
|
+
or not isinstance(expires_at, (int, float))
|
|
247
|
+
):
|
|
248
|
+
raise DispatchAdmissionError("admission does not bind this process")
|
|
249
|
+
if self.identity_probe is None:
|
|
250
|
+
if binding_identity is not None:
|
|
251
|
+
raise DispatchAdmissionError(
|
|
252
|
+
"admission does not bind this process"
|
|
253
|
+
)
|
|
254
|
+
else:
|
|
255
|
+
try:
|
|
256
|
+
observed_file = canonical_json_utf8(binding_identity)
|
|
257
|
+
except Exception as error:
|
|
258
|
+
raise DispatchAdmissionError(
|
|
259
|
+
"admission does not bind this process"
|
|
260
|
+
) from error
|
|
261
|
+
observed_now = self._probe_identity()
|
|
262
|
+
if (
|
|
263
|
+
binding_identity is None
|
|
264
|
+
or observed_file != self.pinned_identity
|
|
265
|
+
or observed_now != self.pinned_identity
|
|
266
|
+
):
|
|
267
|
+
raise DispatchAdmissionError("admission binding identity changed")
|
|
268
|
+
created = float(created_at)
|
|
269
|
+
expires = float(expires_at)
|
|
270
|
+
try:
|
|
271
|
+
now = float(self.clock())
|
|
272
|
+
except (TypeError, ValueError, OverflowError) as error:
|
|
273
|
+
raise DispatchAdmissionError("admission clock failed") from error
|
|
274
|
+
if (
|
|
275
|
+
not math.isfinite(now)
|
|
276
|
+
or not math.isfinite(created)
|
|
277
|
+
or not math.isfinite(expires)
|
|
278
|
+
or now <= 0
|
|
279
|
+
or created <= 0
|
|
280
|
+
or created > now + GATE_CLOCK_SKEW_SECONDS
|
|
281
|
+
or expires <= created
|
|
282
|
+
or expires - created > ADMISSION_MAX_LIFETIME_SECONDS
|
|
283
|
+
or expires <= now
|
|
284
|
+
):
|
|
285
|
+
raise DispatchAdmissionError("admission is not live")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
__all__ = (
|
|
289
|
+
"ADMISSION_FIELDS",
|
|
290
|
+
"ADMISSION_MAX_LIFETIME_SECONDS",
|
|
291
|
+
"ADMISSION_SCHEMA",
|
|
292
|
+
"AUTHORIZATION_ID_RE",
|
|
293
|
+
"DispatchAdmission",
|
|
294
|
+
"DispatchAdmissionError",
|
|
295
|
+
"FileDispatchAdmission",
|
|
296
|
+
"sqlite_database_identity",
|
|
297
|
+
)
|