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 +20 -0
- mitrity/admission/__init__.py +100 -0
- mitrity/admission/_canonical.py +72 -0
- mitrity/admission/_client.py +497 -0
- mitrity/admission/_config.py +209 -0
- mitrity/admission/_errors.py +48 -0
- mitrity/admission/_types.py +235 -0
- mitrity/claude_agent_sdk/__init__.py +24 -0
- mitrity/claude_agent_sdk/_governor.py +513 -0
- mitrity/crewai/__init__.py +24 -0
- mitrity/crewai/_governed.py +333 -0
- mitrity/langchain/__init__.py +24 -0
- mitrity/langchain/_governed.py +373 -0
- mitrity/openai_agents/__init__.py +34 -0
- mitrity/openai_agents/_governed.py +1115 -0
- mitrity/py.typed +0 -0
- mitrity-0.2.0.dist-info/METADATA +341 -0
- mitrity-0.2.0.dist-info/RECORD +20 -0
- mitrity-0.2.0.dist-info/WHEEL +4 -0
- mitrity-0.2.0.dist-info/licenses/LICENSE +202 -0
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
|
+
)
|