tkach-security-client 0.1.1__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.
tkach_client.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# Tkach Security
|
|
2
|
+
#
|
|
3
|
+
# Copyright 2026 ECD5A
|
|
4
|
+
# Licensed under the Apache License, Version 2.0.
|
|
5
|
+
#
|
|
6
|
+
# Repository: https://github.com/ECD5A/Tkach-Security
|
|
7
|
+
#
|
|
8
|
+
# See LICENSE and SECURITY.md.
|
|
9
|
+
|
|
10
|
+
"""Thin, bounded Python client for the Tkach loopback HTTP contract.
|
|
11
|
+
|
|
12
|
+
This module deliberately contains transport checks only. It does not implement
|
|
13
|
+
Krosna, Zaslon, Ruslo, Propusk, provider orchestration, retries, or authority.
|
|
14
|
+
The Rust runtime remains the security boundary; this client only carries a
|
|
15
|
+
request to that boundary and returns bounded transport observations.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import http.client
|
|
21
|
+
import ipaddress
|
|
22
|
+
import json
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from enum import Enum
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# These values mirror the published v0.1 Rust transport contract. A future
|
|
29
|
+
# incompatible wire contract must publish a new SDK contract instead of
|
|
30
|
+
# silently widening these limits.
|
|
31
|
+
MAX_HTTP_BODY_BYTES = 64 * 1024 - 256 - 256
|
|
32
|
+
MAX_HTTP_HEADER_BYTES = 16 * 1024
|
|
33
|
+
MAX_RUNTIME_AUTH_BYTES = 256
|
|
34
|
+
MAX_RUNTIME_ID_BYTES = 128
|
|
35
|
+
MAX_RUNTIME_RESPONSE_BYTES = 128 * 1024
|
|
36
|
+
HTTP_TIMEOUT_SECONDS = 0.5
|
|
37
|
+
|
|
38
|
+
_ID_BYTES = frozenset(b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._:-")
|
|
39
|
+
_AUTH_BYTES = _ID_BYTES
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ErrorCode(str, Enum):
|
|
43
|
+
"""Stable, payload-free client failure categories."""
|
|
44
|
+
|
|
45
|
+
CLOSED = "client_closed"
|
|
46
|
+
INVALID_AUTHENTICATION = "invalid_authentication"
|
|
47
|
+
INVALID_REQUEST = "invalid_request"
|
|
48
|
+
INVALID_RESPONSE = "invalid_response"
|
|
49
|
+
IO = "io_failure"
|
|
50
|
+
NON_LOOPBACK_ADDRESS = "non_loopback_address"
|
|
51
|
+
RESPONSE_TOO_LARGE = "response_too_large"
|
|
52
|
+
UNEXPECTED_HEALTH_RESPONSE = "unexpected_health_response"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TkachClientError(Exception):
|
|
56
|
+
"""A static client error that never includes request or token material."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, code: ErrorCode):
|
|
59
|
+
self.code = code
|
|
60
|
+
super().__init__(code.value)
|
|
61
|
+
|
|
62
|
+
def __repr__(self) -> str:
|
|
63
|
+
return f"TkachClientError({self.code.value!r})"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class ClientResponse:
|
|
68
|
+
"""A bounded HTTP observation; the body has no policy meaning by itself."""
|
|
69
|
+
|
|
70
|
+
status_code: int
|
|
71
|
+
body: bytes
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def is_success(self) -> bool:
|
|
75
|
+
"""Return whether the peer returned a 2xx status."""
|
|
76
|
+
|
|
77
|
+
return 200 <= self.status_code < 300
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class TkachClient:
|
|
81
|
+
"""Loopback-only client for the published Tkach HTTP adapter.
|
|
82
|
+
|
|
83
|
+
``host`` must be a numeric loopback address. DNS names are rejected so a
|
|
84
|
+
later resolver result cannot silently move the client outside the reviewed
|
|
85
|
+
local boundary. Requests are sent once and are never retried.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(self, host: str = "127.0.0.1", port: int = 8080, bearer_token: str = ""):
|
|
89
|
+
if not isinstance(host, str) or not host:
|
|
90
|
+
raise TkachClientError(ErrorCode.NON_LOOPBACK_ADDRESS)
|
|
91
|
+
try:
|
|
92
|
+
address = ipaddress.ip_address(host)
|
|
93
|
+
except ValueError as error:
|
|
94
|
+
raise TkachClientError(ErrorCode.NON_LOOPBACK_ADDRESS) from error
|
|
95
|
+
if not address.is_loopback:
|
|
96
|
+
raise TkachClientError(ErrorCode.NON_LOOPBACK_ADDRESS)
|
|
97
|
+
if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535:
|
|
98
|
+
raise TkachClientError(ErrorCode.INVALID_REQUEST)
|
|
99
|
+
self._host = host
|
|
100
|
+
self._port = port
|
|
101
|
+
self._bearer_token = _validated_token(bearer_token)
|
|
102
|
+
self._closed = False
|
|
103
|
+
|
|
104
|
+
def __enter__(self) -> TkachClient:
|
|
105
|
+
return self
|
|
106
|
+
|
|
107
|
+
def __exit__(self, _exc_type: Any, _exc_value: Any, _traceback: Any) -> None:
|
|
108
|
+
self.close()
|
|
109
|
+
|
|
110
|
+
def __repr__(self) -> str:
|
|
111
|
+
return f"TkachClient(host={self._host!r}, port={self._port}, bearer_token=<redacted>)"
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def address(self) -> tuple[str, int]:
|
|
115
|
+
"""Return the validated numeric loopback endpoint."""
|
|
116
|
+
|
|
117
|
+
return self._host, self._port
|
|
118
|
+
|
|
119
|
+
def close(self) -> None:
|
|
120
|
+
"""Best-effort wipe the client's owned token buffer and close it."""
|
|
121
|
+
|
|
122
|
+
for index in range(len(self._bearer_token)):
|
|
123
|
+
self._bearer_token[index] = 0
|
|
124
|
+
self._closed = True
|
|
125
|
+
|
|
126
|
+
def health(self) -> None:
|
|
127
|
+
"""Require the exact unauthenticated ``/healthz`` liveness response."""
|
|
128
|
+
|
|
129
|
+
response = self._exchange("GET", "/healthz", None)
|
|
130
|
+
if response.status_code != 200 or response.body != b'{"status":"ok"}':
|
|
131
|
+
raise TkachClientError(ErrorCode.UNEXPECTED_HEALTH_RESPONSE)
|
|
132
|
+
|
|
133
|
+
def run(self, request_id: str, lifecycle_id: str, request: Any) -> ClientResponse:
|
|
134
|
+
"""Send one bounded request and return a transport-only observation.
|
|
135
|
+
|
|
136
|
+
The nested request is serialized as data and remains subject to the
|
|
137
|
+
server's strict Gateway parser. This method does not interpret policy,
|
|
138
|
+
create a capability, retry an effect, or turn a non-2xx response into
|
|
139
|
+
authority.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
_validate_identifier(request_id)
|
|
143
|
+
_validate_identifier(lifecycle_id)
|
|
144
|
+
try:
|
|
145
|
+
encoded_request = json.dumps(
|
|
146
|
+
{
|
|
147
|
+
"request_id": request_id,
|
|
148
|
+
"lifecycle_id": lifecycle_id,
|
|
149
|
+
"request": request,
|
|
150
|
+
},
|
|
151
|
+
allow_nan=False,
|
|
152
|
+
ensure_ascii=False,
|
|
153
|
+
separators=(",", ":"),
|
|
154
|
+
).encode("utf-8")
|
|
155
|
+
except (TypeError, UnicodeError, ValueError) as error:
|
|
156
|
+
raise TkachClientError(ErrorCode.INVALID_REQUEST) from error
|
|
157
|
+
if len(encoded_request) > MAX_HTTP_BODY_BYTES:
|
|
158
|
+
raise TkachClientError(ErrorCode.INVALID_REQUEST)
|
|
159
|
+
return self._exchange("POST", "/v1/run", encoded_request)
|
|
160
|
+
|
|
161
|
+
def _exchange(self, method: str, path: str, body: bytes | None) -> ClientResponse:
|
|
162
|
+
if self._closed:
|
|
163
|
+
raise TkachClientError(ErrorCode.CLOSED)
|
|
164
|
+
headers = {"Connection": "close"}
|
|
165
|
+
if body is not None:
|
|
166
|
+
headers["Authorization"] = "Bearer " + bytes(self._bearer_token).decode("ascii")
|
|
167
|
+
headers["Content-Type"] = "application/json"
|
|
168
|
+
connection = http.client.HTTPConnection(
|
|
169
|
+
self._host,
|
|
170
|
+
self._port,
|
|
171
|
+
timeout=HTTP_TIMEOUT_SECONDS,
|
|
172
|
+
)
|
|
173
|
+
try:
|
|
174
|
+
connection.request(method, path, body=body, headers=headers)
|
|
175
|
+
return _read_response(connection.getresponse())
|
|
176
|
+
except TkachClientError:
|
|
177
|
+
raise
|
|
178
|
+
except (http.client.HTTPException, OSError, UnicodeError) as error:
|
|
179
|
+
raise TkachClientError(ErrorCode.IO) from error
|
|
180
|
+
finally:
|
|
181
|
+
connection.close()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _validated_token(value: str) -> bytearray:
|
|
185
|
+
if not isinstance(value, str):
|
|
186
|
+
raise TkachClientError(ErrorCode.INVALID_AUTHENTICATION)
|
|
187
|
+
try:
|
|
188
|
+
encoded = value.encode("ascii")
|
|
189
|
+
except UnicodeEncodeError as error:
|
|
190
|
+
raise TkachClientError(ErrorCode.INVALID_AUTHENTICATION) from error
|
|
191
|
+
if not encoded or len(encoded) > MAX_RUNTIME_AUTH_BYTES or any(
|
|
192
|
+
byte not in _AUTH_BYTES for byte in encoded
|
|
193
|
+
):
|
|
194
|
+
raise TkachClientError(ErrorCode.INVALID_AUTHENTICATION)
|
|
195
|
+
return bytearray(encoded)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _validate_identifier(value: str) -> None:
|
|
199
|
+
try:
|
|
200
|
+
encoded = value.encode("ascii")
|
|
201
|
+
except (AttributeError, UnicodeEncodeError) as error:
|
|
202
|
+
raise TkachClientError(ErrorCode.INVALID_REQUEST) from error
|
|
203
|
+
if not encoded or len(encoded) > MAX_RUNTIME_ID_BYTES or any(
|
|
204
|
+
byte not in _ID_BYTES for byte in encoded
|
|
205
|
+
):
|
|
206
|
+
raise TkachClientError(ErrorCode.INVALID_REQUEST)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _read_response(response: http.client.HTTPResponse) -> ClientResponse:
|
|
210
|
+
status_line = (
|
|
211
|
+
f"HTTP/{response.version // 10}.{response.version % 10} "
|
|
212
|
+
f"{response.status} {response.reason}\r\n"
|
|
213
|
+
).encode("latin-1", "replace")
|
|
214
|
+
if len(status_line) + len(response.msg.as_bytes()) > MAX_HTTP_HEADER_BYTES:
|
|
215
|
+
raise TkachClientError(ErrorCode.RESPONSE_TOO_LARGE)
|
|
216
|
+
transfer_encoding = response.headers.get_all("Transfer-Encoding") or []
|
|
217
|
+
if transfer_encoding:
|
|
218
|
+
raise TkachClientError(ErrorCode.INVALID_RESPONSE)
|
|
219
|
+
content_types = response.headers.get_all("Content-Type") or []
|
|
220
|
+
if content_types != ["application/json"]:
|
|
221
|
+
raise TkachClientError(ErrorCode.INVALID_RESPONSE)
|
|
222
|
+
content_lengths = response.headers.get_all("Content-Length") or []
|
|
223
|
+
if len(content_lengths) != 1 or not content_lengths[0] or not content_lengths[0].isdigit():
|
|
224
|
+
raise TkachClientError(ErrorCode.INVALID_RESPONSE)
|
|
225
|
+
try:
|
|
226
|
+
content_length = int(content_lengths[0], 10)
|
|
227
|
+
except ValueError as error:
|
|
228
|
+
raise TkachClientError(ErrorCode.INVALID_RESPONSE) from error
|
|
229
|
+
if content_length > MAX_RUNTIME_RESPONSE_BYTES:
|
|
230
|
+
raise TkachClientError(ErrorCode.RESPONSE_TOO_LARGE)
|
|
231
|
+
try:
|
|
232
|
+
body = response.read(content_length)
|
|
233
|
+
except (http.client.HTTPException, OSError) as error:
|
|
234
|
+
raise TkachClientError(ErrorCode.IO) from error
|
|
235
|
+
if len(body) != content_length:
|
|
236
|
+
raise TkachClientError(ErrorCode.INVALID_RESPONSE)
|
|
237
|
+
return ClientResponse(response.status, body)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tkach-security-client
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Thin, bounded Python client for the Tkach Security loopback HTTP contract
|
|
5
|
+
Author: ECD5A
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Keywords: security,ai,authorization,http
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Topic :: Security
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# Tkach Python adapter
|
|
18
|
+
|
|
19
|
+
This is a deliberately small, standard-library-only client for the reviewed
|
|
20
|
+
local HTTP contract. It is not a Python reimplementation of Tkach Core.
|
|
21
|
+
|
|
22
|
+
Install the local package while the public PyPI release is still deferred:
|
|
23
|
+
|
|
24
|
+
```text
|
|
25
|
+
python -m pip install ./sdk/python
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from tkach_client import TkachClient
|
|
30
|
+
|
|
31
|
+
with TkachClient("127.0.0.1", 8080, "local-development-secret") as tkach:
|
|
32
|
+
tkach.health()
|
|
33
|
+
response = tkach.run(
|
|
34
|
+
"request-1",
|
|
35
|
+
"lifecycle-1",
|
|
36
|
+
{"messages": [{"role": "user", "content": "hello"}]},
|
|
37
|
+
)
|
|
38
|
+
print(response.status_code, response.body)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The adapter accepts numeric loopback IP addresses only, uses one bounded HTTP
|
|
42
|
+
request without retries, rejects chunked/ambiguous/oversized responses, and
|
|
43
|
+
keeps policy interpretation inside the Rust runtime. It is a local carrier,
|
|
44
|
+
not TLS, process isolation, a public client, or an authority API.
|
|
45
|
+
|
|
46
|
+
Run the offline contract tests from this directory:
|
|
47
|
+
|
|
48
|
+
```text
|
|
49
|
+
python -m unittest -v
|
|
50
|
+
```
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
tkach_client.py,sha256=OZnDUpYGT17kk-LOE2LdLC2X1W6z3G6tZv8yOAGroao,8998
|
|
2
|
+
tkach_security_client-0.1.1.dist-info/METADATA,sha256=eChjBCg4NqLntWVBvxQ2mdbBcA8Vs8HvuUOLxbQu-_8,1585
|
|
3
|
+
tkach_security_client-0.1.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
4
|
+
tkach_security_client-0.1.1.dist-info/top_level.txt,sha256=-6rg7kW3jqHag1pq1oVfctqlRWifMRl0iJ9qUd9tbrM,13
|
|
5
|
+
tkach_security_client-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tkach_client
|