mitrity 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.
mitrity/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """MITRITY governance adapter for Python agents.
2
+
3
+ The package connects an agent framework's tool execution to the co-located
4
+ MITRITY edge: built-in tools are admitted through the loopback admission API
5
+ before they run, MCP tools reach the model through the MITRITY gateway, and the
6
+ runtime's governance posture is attested so the control plane can render an
7
+ honest coverage badge.
8
+
9
+ - :mod:`mitrity.admission` — the wire client (no framework dependency).
10
+ - :mod:`mitrity.claude_agent_sdk` — ``governed_options()`` for the Claude Agent SDK.
11
+ - :mod:`mitrity.langchain` — ``govern()`` / ``govern_tools()`` for LangChain tools.
12
+ - :mod:`mitrity.openai_agents` — ``govern()`` / ``govern_tools()`` for the OpenAI Agents SDK.
13
+ - :mod:`mitrity.crewai` — ``govern()`` / ``govern_tools()`` for CrewAI tools.
14
+
15
+ Contract: https://github.com/mitrity-io/iag-specs/blob/main/sentinel/adapters.md
16
+ """
17
+
18
+ __version__ = "0.2.0"
19
+
20
+ __all__ = ["__version__"]
@@ -0,0 +1,100 @@
1
+ """The MITRITY admission client.
2
+
3
+ Contract: https://github.com/mitrity-io/iag-specs/blob/main/sentinel/admission-api.md
4
+ Adapter guarantees: https://github.com/mitrity-io/iag-specs/blob/main/sentinel/adapters.md
5
+ """
6
+
7
+ from ._canonical import canonical_json, config_hash
8
+ from ._client import (
9
+ HEADER_TOKEN,
10
+ HEADER_VERSION,
11
+ MAX_REQUEST_BYTES,
12
+ PROTOCOL_VERSION,
13
+ UNREACHABLE_HINT,
14
+ Client,
15
+ )
16
+ from ._config import (
17
+ ATTEST_TIMEOUT,
18
+ DEFAULT_HOLD_TIMEOUT,
19
+ DEFAULT_TIMEOUT,
20
+ ENV_ADDR,
21
+ ENV_HOLD_TIMEOUT,
22
+ ENV_TIMEOUT,
23
+ ENV_TOKEN_FILE,
24
+ HOLD_MARGIN,
25
+ MAX_HOLD_TIMEOUT,
26
+ MAX_TIMEOUT,
27
+ Config,
28
+ parse_duration,
29
+ parse_loopback_addr,
30
+ platform_defaults,
31
+ split_addr,
32
+ validate_addr,
33
+ )
34
+ from ._errors import (
35
+ AdmissionConfigError,
36
+ AdmissionError,
37
+ AdmissionNotReady,
38
+ AdmissionPayloadTooLarge,
39
+ AdmissionProtocolError,
40
+ AdmissionTimeout,
41
+ AdmissionUnauthorized,
42
+ AdmissionUnreachable,
43
+ )
44
+ from ._types import (
45
+ ADAPTER_NAME,
46
+ EXEC_CAPABLE_TOOLS,
47
+ FRAMEWORK_FOR_SURFACE,
48
+ AdmitRequest,
49
+ Attestation,
50
+ Decision,
51
+ DecisionKind,
52
+ SandboxPosture,
53
+ Surface,
54
+ Verdict,
55
+ )
56
+
57
+ __all__ = [
58
+ "ADAPTER_NAME",
59
+ "ATTEST_TIMEOUT",
60
+ "DEFAULT_HOLD_TIMEOUT",
61
+ "DEFAULT_TIMEOUT",
62
+ "ENV_ADDR",
63
+ "ENV_HOLD_TIMEOUT",
64
+ "ENV_TIMEOUT",
65
+ "ENV_TOKEN_FILE",
66
+ "EXEC_CAPABLE_TOOLS",
67
+ "FRAMEWORK_FOR_SURFACE",
68
+ "HEADER_TOKEN",
69
+ "HEADER_VERSION",
70
+ "HOLD_MARGIN",
71
+ "MAX_HOLD_TIMEOUT",
72
+ "MAX_REQUEST_BYTES",
73
+ "MAX_TIMEOUT",
74
+ "PROTOCOL_VERSION",
75
+ "UNREACHABLE_HINT",
76
+ "AdmissionConfigError",
77
+ "AdmissionError",
78
+ "AdmissionNotReady",
79
+ "AdmissionPayloadTooLarge",
80
+ "AdmissionProtocolError",
81
+ "AdmissionTimeout",
82
+ "AdmissionUnauthorized",
83
+ "AdmissionUnreachable",
84
+ "AdmitRequest",
85
+ "Attestation",
86
+ "Client",
87
+ "Config",
88
+ "Decision",
89
+ "DecisionKind",
90
+ "SandboxPosture",
91
+ "Surface",
92
+ "Verdict",
93
+ "canonical_json",
94
+ "config_hash",
95
+ "parse_duration",
96
+ "parse_loopback_addr",
97
+ "platform_defaults",
98
+ "split_addr",
99
+ "validate_addr",
100
+ ]
@@ -0,0 +1,72 @@
1
+ """RFC 8785 (JSON Canonicalization Scheme) for the values an attestation hashes.
2
+
3
+ Two independent implementations hash the same configuration — this adapter
4
+ and whatever later checks a runtime against it — so the byte sequence has to
5
+ be pinned rather than left to a JSON library's defaults. The subset here is
6
+ what a governed configuration contains: strings, booleans, integers, ``None``,
7
+ lists and objects. Floats are refused on purpose: nothing in the hashed
8
+ document is a float, and JCS float formatting is the one part worth not
9
+ getting subtly wrong.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ from collections.abc import Mapping, Sequence
16
+ from typing import Any
17
+
18
+ _SHORT_ESCAPES = {
19
+ "\b": "\\b",
20
+ "\t": "\\t",
21
+ "\n": "\\n",
22
+ "\f": "\\f",
23
+ "\r": "\\r",
24
+ '"': '\\"',
25
+ "\\": "\\\\",
26
+ }
27
+
28
+
29
+ def _escape(text: str) -> str:
30
+ out: list[str] = ['"']
31
+ for char in text:
32
+ short = _SHORT_ESCAPES.get(char)
33
+ if short is not None:
34
+ out.append(short)
35
+ elif ord(char) < 0x20:
36
+ out.append(f"\\u{ord(char):04x}")
37
+ else:
38
+ out.append(char)
39
+ out.append('"')
40
+ return "".join(out)
41
+
42
+
43
+ def canonical_json(value: Any) -> str: # noqa: PLR0911 — one return per JSON type
44
+ """Serialize ``value`` per RFC 8785: sorted members, no whitespace, minimal escapes."""
45
+ if value is None:
46
+ return "null"
47
+ if value is True:
48
+ return "true"
49
+ if value is False:
50
+ return "false"
51
+ if isinstance(value, int):
52
+ return str(value)
53
+ if isinstance(value, str):
54
+ return _escape(value)
55
+ if isinstance(value, bytes | bytearray | memoryview):
56
+ raise TypeError("canonical_json does not serialize bytes")
57
+ if isinstance(value, Mapping):
58
+ items = sorted(
59
+ ((str(key), inner) for key, inner in value.items()),
60
+ key=lambda item: item[0].encode("utf-16-be"),
61
+ )
62
+ return (
63
+ "{" + ",".join(f"{_escape(key)}:{canonical_json(inner)}" for key, inner in items) + "}"
64
+ )
65
+ if isinstance(value, Sequence):
66
+ return "[" + ",".join(canonical_json(inner) for inner in value) + "]"
67
+ raise TypeError(f"canonical_json does not serialize {type(value).__name__}")
68
+
69
+
70
+ def config_hash(value: Any) -> str:
71
+ """SHA-256, hex, over the canonical serialization of ``value``."""
72
+ return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
@@ -0,0 +1,497 @@
1
+ """The admission client: one authenticated request, and the two-phase decision.
2
+
3
+ Every failure — an unreachable socket, a token the edge rejects, a ``503``,
4
+ a body that is not a decision, a deadline exceeded — is an exception from
5
+ ``admit`` and a deny from ``decide``. There is no path through this module
6
+ that produces an allow the edge did not send for this request.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import time
14
+ from collections.abc import AsyncIterator, Iterator
15
+ from dataclasses import replace
16
+ from typing import Any
17
+
18
+ import httpx
19
+
20
+ from ._config import (
21
+ ATTEST_TIMEOUT,
22
+ HOLD_MARGIN,
23
+ Config,
24
+ parse_loopback_addr,
25
+ split_addr,
26
+ validate_addr,
27
+ )
28
+ from ._errors import (
29
+ AdmissionConfigError,
30
+ AdmissionError,
31
+ AdmissionNotReady,
32
+ AdmissionPayloadTooLarge,
33
+ AdmissionProtocolError,
34
+ AdmissionTimeout,
35
+ AdmissionUnauthorized,
36
+ AdmissionUnreachable,
37
+ )
38
+ from ._types import AdmitRequest, Attestation, Decision, Verdict
39
+
40
+ PROTOCOL_VERSION = "1"
41
+ """The admission protocol this adapter speaks. Sent on every request; nothing else is accepted."""
42
+
43
+ HEADER_TOKEN = "X-Mitrity-Admission-Token"
44
+ HEADER_VERSION = "X-Mitrity-Admission-Version"
45
+
46
+ MAX_REQUEST_BYTES = 64 * 1024
47
+ """The edge's request-body cap. A larger input is denied here, without sending it."""
48
+
49
+ _MAX_RESPONSE_BYTES = 256 * 1024
50
+ _HOST = "mitrity-admission"
51
+ _SUMMARY_LIMIT = 200
52
+
53
+ logger = logging.getLogger("mitrity.admission")
54
+
55
+ UNREACHABLE_HINT = (
56
+ "This is not a policy decision — the governance edge could not be reached. "
57
+ "Check that the MITRITY edge is running and that MITRITY_ADMISSION_ADDR names its "
58
+ "admission socket."
59
+ )
60
+
61
+
62
+ def _summarize(body: bytes) -> str:
63
+ """Bound an error body before it reaches a message the model will read."""
64
+ text = body.decode("utf-8", "replace").strip()
65
+ text = "".join(" " if ord(c) < 0x20 or ord(c) == 0x7F else c for c in text)
66
+ if len(text) > _SUMMARY_LIMIT:
67
+ return text[:_SUMMARY_LIMIT] + "…"
68
+ return text
69
+
70
+
71
+ def _read_token(path: str) -> str:
72
+ """Read the per-process admission token, fresh, so a rotated token is picked up."""
73
+ if not path:
74
+ raise AdmissionConfigError(
75
+ "no admission token file configured (set MITRITY_ADMISSION_TOKEN_FILE)"
76
+ )
77
+ try:
78
+ with open(path, encoding="utf-8") as handle:
79
+ token = handle.read().strip()
80
+ except OSError as exc:
81
+ raise AdmissionUnreachable(
82
+ f"admission token file {path!r} could not be read ({exc.strerror or exc}): "
83
+ "the MITRITY edge is not running here, or is not configured to serve admission"
84
+ ) from exc
85
+ if not token:
86
+ raise AdmissionUnreachable(f"admission token file {path!r} is empty")
87
+ return token
88
+
89
+
90
+ def _encode(body: Any) -> bytes | None:
91
+ if body is None:
92
+ return None
93
+ payload = json.dumps(body, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
94
+ if len(payload) > MAX_REQUEST_BYTES:
95
+ raise AdmissionPayloadTooLarge(
96
+ f"the tool input is larger than MITRITY will judge ({len(payload)} bytes, "
97
+ f"cap {MAX_REQUEST_BYTES})"
98
+ )
99
+ return payload
100
+
101
+
102
+ def _interpret(status: int, body: bytes, headers: httpx.Headers, *, addr: str) -> Any:
103
+ """Turn a status and body into parsed JSON, or the error the contract implies."""
104
+ version = headers.get(HEADER_VERSION)
105
+ if version is not None and version != PROTOCOL_VERSION:
106
+ raise AdmissionProtocolError(
107
+ f"admission API at {addr} speaks protocol version {version!r}; this adapter speaks "
108
+ f"{PROTOCOL_VERSION!r} — upgrade the edge or the adapter"
109
+ )
110
+ if status == 204:
111
+ return None
112
+ if status == 200 and version is None:
113
+ # Nothing authenticates the edge to the adapter; the version header is the one
114
+ # signal that the peer is a MITRITY edge. A decision without it is not obeyed.
115
+ raise AdmissionProtocolError(
116
+ f"admission API at {addr} answered without an {HEADER_VERSION} header — "
117
+ "not a MITRITY edge, or a protocol the adapter does not speak"
118
+ )
119
+ if status == 503:
120
+ raise AdmissionNotReady(f"the MITRITY edge is not ready to judge ({_reason_or(body)})")
121
+ if status == 400:
122
+ raise AdmissionProtocolError(
123
+ f"admission API rejected the request (400): {_reason_or(body)}"
124
+ )
125
+ if status != 200:
126
+ raise AdmissionProtocolError(f"admission API returned {status}: {_summarize(body)}")
127
+ try:
128
+ return json.loads(body)
129
+ except ValueError as exc:
130
+ raise AdmissionProtocolError("admission response was not valid JSON") from exc
131
+
132
+
133
+ def _reason_or(body: bytes) -> str:
134
+ """The ``reason``/``error`` field of a JSON error body, else a bounded summary of it."""
135
+ try:
136
+ data = json.loads(body)
137
+ except ValueError:
138
+ return _summarize(body)
139
+ if isinstance(data, dict):
140
+ for key in ("reason", "error"):
141
+ value = data.get(key)
142
+ if isinstance(value, str) and value:
143
+ return _summarize(value.encode("utf-8"))
144
+ return _summarize(body)
145
+
146
+
147
+ def _limits() -> httpx.Limits:
148
+ # No keep-alive: the edge idles connections out and a stale pooled socket
149
+ # would surface as a spurious transport error on the next decision.
150
+ return httpx.Limits(max_keepalive_connections=0)
151
+
152
+
153
+ class Client:
154
+ """Talks to the loopback admission API.
155
+
156
+ Safe to share across threads and tasks: it holds configuration only and
157
+ opens one short-lived connection per request, reading the token file each
158
+ time so an edge restart is picked up without the caller caring.
159
+ """
160
+
161
+ def __init__(
162
+ self,
163
+ config: Config | None = None,
164
+ *,
165
+ addr: str | None = None,
166
+ token_file: str | None = None,
167
+ timeout: float | None = None,
168
+ hold_timeout: float | None = None,
169
+ ) -> None:
170
+ cfg = config if config is not None else Config.from_env()
171
+ cfg = replace(
172
+ cfg,
173
+ addr=addr if addr is not None else cfg.addr,
174
+ token_file=token_file if token_file is not None else cfg.token_file,
175
+ timeout=timeout if timeout is not None else cfg.timeout,
176
+ hold_timeout=hold_timeout if hold_timeout is not None else cfg.hold_timeout,
177
+ )
178
+ self._config = cfg
179
+ self._config_error: AdmissionConfigError | None = None
180
+ try:
181
+ validate_addr(cfg.addr)
182
+ except AdmissionConfigError as exc:
183
+ # Not raised here on purpose: a hook that crashes at construction
184
+ # takes the application down; one that denies every call is what
185
+ # fail-closed means.
186
+ self._config_error = exc
187
+ self._network, self._address = split_addr(cfg.addr)
188
+ self._host = ""
189
+ self._port = 0
190
+ if self._network == "tcp" and self._config_error is None:
191
+ # The URL is built from the parsed literal host and port, never from
192
+ # the configured string, so nothing in it can name another authority.
193
+ self._host, self._port = parse_loopback_addr(self._address)
194
+
195
+ @property
196
+ def config(self) -> Config:
197
+ return self._config
198
+
199
+ # ------------------------------------------------------------------ sync
200
+
201
+ def admit(self, request: AdmitRequest, *, timeout: float | None = None) -> Decision:
202
+ """Ask for a decision. Raises :class:`AdmissionError` on any failure."""
203
+ started = time.monotonic()
204
+ data = self._request(
205
+ "POST", "/v1/admit", request.to_wire(), timeout or self._config.timeout
206
+ )
207
+ decision = Decision.from_wire(data)
208
+ self._log(request, decision, started)
209
+ return decision
210
+
211
+ def attest(self, attestation: Attestation, *, timeout: float | None = None) -> None:
212
+ """Report the runtime's posture. Raises on failure; the caller logs, never blocks."""
213
+ self._request("POST", "/v1/attest", attestation.to_wire(), timeout or ATTEST_TIMEOUT)
214
+
215
+ def health(self, *, timeout: float | None = None) -> dict[str, Any]:
216
+ """``GET /healthz``, unauthenticated. Useful for a doctor-style check."""
217
+ data = self._request("GET", "/healthz", None, timeout or self._config.timeout, auth=False)
218
+ return data if isinstance(data, dict) else {}
219
+
220
+ def decide(self, request: AdmitRequest, *, hold_timeout: float | None = None) -> Verdict:
221
+ """The two-phase decision: never raises, never fabricates an allow.
222
+
223
+ Phase 1 asks with ``hold_timeout_seconds: 0`` under the deadline. Only
224
+ a ``held`` answer starts phase 2, which re-submits with the hold budget
225
+ so the edge long-polls the approval. Anything that goes wrong on
226
+ either phase is a deny naming what went wrong. ``hold_timeout`` caps
227
+ the configured budget for this call; it can never raise it.
228
+ """
229
+ try:
230
+ first = self.admit(replace(request, hold_timeout_seconds=0))
231
+ except AdmissionError as exc:
232
+ return _unreachable_verdict(exc)
233
+ if first.decision != "held":
234
+ return _verdict_for(first)
235
+ budget = self._hold_budget(hold_timeout)
236
+ if budget <= 0:
237
+ return _verdict_for(first)
238
+ try:
239
+ second = self.admit(
240
+ replace(request, hold_timeout_seconds=budget), timeout=budget + HOLD_MARGIN
241
+ )
242
+ except AdmissionError as exc:
243
+ return _hold_failed_verdict(first, exc)
244
+ return _verdict_for(second)
245
+
246
+ # ----------------------------------------------------------------- async
247
+
248
+ async def admit_async(self, request: AdmitRequest, *, timeout: float | None = None) -> Decision:
249
+ started = time.monotonic()
250
+ data = await self._request_async(
251
+ "POST", "/v1/admit", request.to_wire(), timeout or self._config.timeout
252
+ )
253
+ decision = Decision.from_wire(data)
254
+ self._log(request, decision, started)
255
+ return decision
256
+
257
+ async def attest_async(self, attestation: Attestation, *, timeout: float | None = None) -> None:
258
+ await self._request_async(
259
+ "POST", "/v1/attest", attestation.to_wire(), timeout or ATTEST_TIMEOUT
260
+ )
261
+
262
+ async def health_async(self, *, timeout: float | None = None) -> dict[str, Any]:
263
+ data = await self._request_async(
264
+ "GET", "/healthz", None, timeout or self._config.timeout, auth=False
265
+ )
266
+ return data if isinstance(data, dict) else {}
267
+
268
+ async def decide_async(
269
+ self, request: AdmitRequest, *, hold_timeout: float | None = None
270
+ ) -> Verdict:
271
+ try:
272
+ first = await self.admit_async(replace(request, hold_timeout_seconds=0))
273
+ except AdmissionError as exc:
274
+ return _unreachable_verdict(exc)
275
+ if first.decision != "held":
276
+ return _verdict_for(first)
277
+ budget = self._hold_budget(hold_timeout)
278
+ if budget <= 0:
279
+ return _verdict_for(first)
280
+ try:
281
+ second = await self.admit_async(
282
+ replace(request, hold_timeout_seconds=budget), timeout=budget + HOLD_MARGIN
283
+ )
284
+ except AdmissionError as exc:
285
+ return _hold_failed_verdict(first, exc)
286
+ return _verdict_for(second)
287
+
288
+ # -------------------------------------------------------------- plumbing
289
+
290
+ def _hold_budget(self, cap: float | None) -> int:
291
+ budget = self._config.hold_timeout
292
+ if cap is not None:
293
+ budget = min(budget, max(cap, 0.0))
294
+ return int(budget)
295
+
296
+ def _log(self, request: AdmitRequest, decision: Decision, started: float) -> None:
297
+ # The tool input never reaches a log record, and neither does the
298
+ # reason (it names the resolved command). What is logged is enough to
299
+ # line a decision up against the audit trail.
300
+ logger.debug(
301
+ "admission decision tool=%s decision=%s admission_id=%s risk=%.2f routed_to=%s ms=%d",
302
+ request.tool_name,
303
+ decision.decision,
304
+ decision.admission_id,
305
+ decision.risk_score,
306
+ decision.routed_to,
307
+ int((time.monotonic() - started) * 1000),
308
+ )
309
+
310
+ def _url(self, path: str) -> str:
311
+ if self._network == "unix":
312
+ return f"http://{_HOST}{path}"
313
+ host = f"[{self._host}]" if ":" in self._host else self._host
314
+ return f"http://{host}:{self._port}{path}"
315
+
316
+ def _headers(self, token: str | None, payload: bytes | None) -> dict[str, str]:
317
+ headers = {"Host": _HOST, HEADER_VERSION: PROTOCOL_VERSION}
318
+ if token is not None:
319
+ headers[HEADER_TOKEN] = token
320
+ if payload is not None:
321
+ headers["Content-Type"] = "application/json"
322
+ return headers
323
+
324
+ def _remaining(self, deadline: float) -> float:
325
+ remaining = deadline - time.monotonic()
326
+ if remaining <= 0:
327
+ raise AdmissionTimeout("deadline exceeded before the request was sent")
328
+ return remaining
329
+
330
+ def _timeout_error(self, timeout: float) -> AdmissionTimeout:
331
+ return AdmissionTimeout(
332
+ f"admission API at {self._config.addr} did not answer within {timeout * 1000:.0f} ms"
333
+ )
334
+
335
+ def _unreachable_error(self, exc: Exception) -> AdmissionUnreachable:
336
+ return AdmissionUnreachable(f"admission API at {self._config.addr} unreachable: {exc}")
337
+
338
+ def _request(
339
+ self, method: str, path: str, body: Any, timeout: float, *, auth: bool = True
340
+ ) -> Any:
341
+ if self._config_error is not None:
342
+ raise self._config_error
343
+ deadline = time.monotonic() + timeout
344
+ payload = _encode(body)
345
+ attempt = 0
346
+ while True:
347
+ attempt += 1
348
+ remaining = self._remaining(deadline)
349
+ token = _read_token(self._config.token_file) if auth else None
350
+ transport = (
351
+ httpx.HTTPTransport(uds=self._address, retries=0)
352
+ if self._network == "unix"
353
+ else httpx.HTTPTransport(retries=0)
354
+ )
355
+ try:
356
+ with (
357
+ httpx.Client(
358
+ transport=transport, timeout=httpx.Timeout(remaining), limits=_limits()
359
+ ) as http,
360
+ http.stream(
361
+ method,
362
+ self._url(path),
363
+ content=payload,
364
+ headers=self._headers(token, payload),
365
+ ) as response,
366
+ ):
367
+ status = response.status_code
368
+ headers = response.headers
369
+ data = _collect(response.iter_bytes())
370
+ except httpx.TimeoutException as exc:
371
+ raise self._timeout_error(timeout) from exc
372
+ except httpx.HTTPError as exc:
373
+ raise self._unreachable_error(exc) from exc
374
+ if status == 401:
375
+ # Exactly one retry with a freshly-read token: the edge may have
376
+ # restarted and minted a new one. A file that keeps producing
377
+ # 401s is a broken deployment, not something to loop on.
378
+ if attempt == 1 and deadline - time.monotonic() > 0:
379
+ continue
380
+ raise AdmissionUnauthorized(
381
+ "admission token rejected (401) after re-reading the token file"
382
+ )
383
+ return _interpret(status, data, headers, addr=self._config.addr)
384
+
385
+ async def _request_async(
386
+ self, method: str, path: str, body: Any, timeout: float, *, auth: bool = True
387
+ ) -> Any:
388
+ if self._config_error is not None:
389
+ raise self._config_error
390
+ deadline = time.monotonic() + timeout
391
+ payload = _encode(body)
392
+ attempt = 0
393
+ while True:
394
+ attempt += 1
395
+ remaining = self._remaining(deadline)
396
+ token = _read_token(self._config.token_file) if auth else None
397
+ transport = (
398
+ httpx.AsyncHTTPTransport(uds=self._address, retries=0)
399
+ if self._network == "unix"
400
+ else httpx.AsyncHTTPTransport(retries=0)
401
+ )
402
+ try:
403
+ async with (
404
+ httpx.AsyncClient(
405
+ transport=transport, timeout=httpx.Timeout(remaining), limits=_limits()
406
+ ) as http,
407
+ http.stream(
408
+ method,
409
+ self._url(path),
410
+ content=payload,
411
+ headers=self._headers(token, payload),
412
+ ) as response,
413
+ ):
414
+ status = response.status_code
415
+ headers = response.headers
416
+ data = await _collect_async(response.aiter_bytes())
417
+ except httpx.TimeoutException as exc:
418
+ raise self._timeout_error(timeout) from exc
419
+ except httpx.HTTPError as exc:
420
+ raise self._unreachable_error(exc) from exc
421
+ if status == 401:
422
+ if attempt == 1 and deadline - time.monotonic() > 0:
423
+ continue
424
+ raise AdmissionUnauthorized(
425
+ "admission token rejected (401) after re-reading the token file"
426
+ )
427
+ return _interpret(status, data, headers, addr=self._config.addr)
428
+
429
+
430
+ def _collect(chunks: Iterator[bytes]) -> bytes:
431
+ """Read a response body, bounded: a decision is a few hundred bytes."""
432
+ data = bytearray()
433
+ for chunk in chunks:
434
+ data.extend(chunk)
435
+ if len(data) > _MAX_RESPONSE_BYTES:
436
+ raise AdmissionProtocolError(
437
+ "admission response exceeded the size an adapter will read"
438
+ )
439
+ return bytes(data)
440
+
441
+
442
+ async def _collect_async(chunks: AsyncIterator[bytes]) -> bytes:
443
+ data = bytearray()
444
+ async for chunk in chunks:
445
+ data.extend(chunk)
446
+ if len(data) > _MAX_RESPONSE_BYTES:
447
+ raise AdmissionProtocolError(
448
+ "admission response exceeded the size an adapter will read"
449
+ )
450
+ return bytes(data)
451
+
452
+
453
+ def _verdict_for(decision: Decision) -> Verdict:
454
+ if decision.decision == "allow":
455
+ return Verdict(
456
+ allowed=True,
457
+ reason=decision.reason,
458
+ decision=decision,
459
+ updated_input=decision.updated_input,
460
+ routed_to=decision.routed_to,
461
+ )
462
+ if decision.decision == "held":
463
+ return Verdict(
464
+ allowed=False,
465
+ reason=(
466
+ "MITRITY is holding this action for human approval "
467
+ f"(approval {decision.approval_id or 'unknown'}) and it has not been approved. "
468
+ "Ask the operator to approve it in the MITRITY console, then try again."
469
+ ),
470
+ decision=decision,
471
+ held=True,
472
+ )
473
+ reason = decision.reason or "MITRITY policy denied this action"
474
+ return Verdict(allowed=False, reason=f"MITRITY denied this action: {reason}", decision=decision)
475
+
476
+
477
+ def _unreachable_verdict(exc: AdmissionError) -> Verdict:
478
+ return Verdict(
479
+ allowed=False,
480
+ reason=f"MITRITY could not authorize this action and blocked it: {exc}. {UNREACHABLE_HINT}",
481
+ error=exc,
482
+ )
483
+
484
+
485
+ def _hold_failed_verdict(held: Decision, exc: AdmissionError) -> Verdict:
486
+ return Verdict(
487
+ allowed=False,
488
+ reason=(
489
+ "MITRITY held this action for human approval "
490
+ f"(approval {held.approval_id or 'unknown'}) and waiting on the approval "
491
+ f"failed: {exc}. "
492
+ "The action has not been approved."
493
+ ),
494
+ decision=held,
495
+ error=exc,
496
+ held=True,
497
+ )