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.
@@ -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
+ )