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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ tkach_client