onkernel 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.
- kernel/__init__.py +85 -0
- kernel/client.py +285 -0
- kernel/crypto/__init__.py +12 -0
- kernel/crypto/decrypt.py +39 -0
- kernel/crypto/keys.py +30 -0
- kernel/errors.py +304 -0
- kernel/py.typed +0 -0
- kernel/types.py +257 -0
- onkernel-0.2.0.dist-info/METADATA +182 -0
- onkernel-0.2.0.dist-info/RECORD +12 -0
- onkernel-0.2.0.dist-info/WHEEL +4 -0
- onkernel-0.2.0.dist-info/licenses/LICENSE +21 -0
kernel/__init__.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Kernel SDK — deterministic governance for AI agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings as _warnings
|
|
6
|
+
from typing import Any as _Any
|
|
7
|
+
|
|
8
|
+
__version__ = "0.2.0"
|
|
9
|
+
|
|
10
|
+
import contextlib as _contextlib
|
|
11
|
+
|
|
12
|
+
from kernel.client import Kernel
|
|
13
|
+
from kernel.errors import (
|
|
14
|
+
AuthError,
|
|
15
|
+
CircuitOpenError,
|
|
16
|
+
FeatureNotInTier,
|
|
17
|
+
KernelDeniedError,
|
|
18
|
+
KernelError,
|
|
19
|
+
PlanCeilingHit,
|
|
20
|
+
PlanLimitExceeded,
|
|
21
|
+
PlanSoftWarning,
|
|
22
|
+
PolicyDeniedError,
|
|
23
|
+
RateLimitError,
|
|
24
|
+
ScannerBlockedError,
|
|
25
|
+
WebhookError,
|
|
26
|
+
)
|
|
27
|
+
from kernel.types import (
|
|
28
|
+
ActionResponse,
|
|
29
|
+
ApprovalResult,
|
|
30
|
+
ExecuteResponse,
|
|
31
|
+
KernelOutcome,
|
|
32
|
+
Session,
|
|
33
|
+
TokenResponse,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
with _contextlib.suppress(ImportError):
|
|
37
|
+
from kernel.crypto import decrypt, decrypt_json # noqa: F401 — re-export
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def __getattr__(name: str) -> _Any:
|
|
41
|
+
"""Lazy deprecation for `kernel.ApprovalRequiredError` import.
|
|
42
|
+
|
|
43
|
+
The SDK no longer raises this exception (the 202 path now returns an
|
|
44
|
+
`ActionResponse` with `outcome == KernelOutcome.REQUIRES_HUMAN`). The
|
|
45
|
+
name is kept exported for one minor version so existing
|
|
46
|
+
`from kernel import ApprovalRequiredError` keeps importing. Reading
|
|
47
|
+
the symbol emits a `DeprecationWarning`; the class is removed in v0.3.
|
|
48
|
+
"""
|
|
49
|
+
if name == "ApprovalRequiredError":
|
|
50
|
+
_warnings.warn(
|
|
51
|
+
"ApprovalRequiredError is deprecated and will be removed in v0.3. "
|
|
52
|
+
"The SDK no longer raises on 202 responses; branch on "
|
|
53
|
+
"ActionResponse.outcome (KernelOutcome.REQUIRES_HUMAN) instead. "
|
|
54
|
+
"See CHANGELOG for migration.",
|
|
55
|
+
DeprecationWarning,
|
|
56
|
+
stacklevel=2,
|
|
57
|
+
)
|
|
58
|
+
from kernel.errors import ApprovalRequiredError as _AR
|
|
59
|
+
return _AR
|
|
60
|
+
raise AttributeError(f"module 'kernel' has no attribute {name!r}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
"Kernel",
|
|
65
|
+
"KernelError",
|
|
66
|
+
"AuthError",
|
|
67
|
+
"KernelDeniedError",
|
|
68
|
+
"PolicyDeniedError",
|
|
69
|
+
"ScannerBlockedError",
|
|
70
|
+
"RateLimitError",
|
|
71
|
+
"CircuitOpenError",
|
|
72
|
+
"WebhookError",
|
|
73
|
+
"PlanLimitExceeded",
|
|
74
|
+
"PlanCeilingHit",
|
|
75
|
+
"FeatureNotInTier",
|
|
76
|
+
"PlanSoftWarning",
|
|
77
|
+
"ActionResponse",
|
|
78
|
+
"KernelOutcome",
|
|
79
|
+
"Session",
|
|
80
|
+
"TokenResponse",
|
|
81
|
+
"ExecuteResponse",
|
|
82
|
+
"ApprovalResult",
|
|
83
|
+
# Deprecated, retained one minor version:
|
|
84
|
+
"ApprovalRequiredError",
|
|
85
|
+
]
|
kernel/client.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from kernel import errors as _errors
|
|
10
|
+
from kernel.errors import (
|
|
11
|
+
AuthError,
|
|
12
|
+
CircuitOpenError,
|
|
13
|
+
KernelError,
|
|
14
|
+
PlanSoftWarning,
|
|
15
|
+
RateLimitError,
|
|
16
|
+
WebhookError,
|
|
17
|
+
_build_denied,
|
|
18
|
+
)
|
|
19
|
+
from kernel.types import (
|
|
20
|
+
ActionResponse,
|
|
21
|
+
ApprovalResult,
|
|
22
|
+
ExecuteResponse,
|
|
23
|
+
Session,
|
|
24
|
+
TokenResponse,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Kernel:
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
api_key: str,
|
|
32
|
+
base_url: str = "http://localhost:8080",
|
|
33
|
+
timeout: float = 30.0,
|
|
34
|
+
on_plan_soft_warning: Callable[[PlanSoftWarning], None] | None = None,
|
|
35
|
+
):
|
|
36
|
+
"""
|
|
37
|
+
Args:
|
|
38
|
+
api_key: Kernel agent API key (ok_live_… / ok_sand_…).
|
|
39
|
+
base_url: Proxy URL.
|
|
40
|
+
timeout: Per-request timeout in seconds.
|
|
41
|
+
on_plan_soft_warning: Optional callback invoked when the request
|
|
42
|
+
succeeds but the workspace crossed its soft warning threshold
|
|
43
|
+
this request (Pricing spec §2.4 warning envelope). The callback
|
|
44
|
+
receives a PlanSoftWarning with limit/current/cap/message. The
|
|
45
|
+
request still succeeded normally — this is a billing nudge, not
|
|
46
|
+
an error. Fires at most once per period.
|
|
47
|
+
"""
|
|
48
|
+
self.api_key = api_key
|
|
49
|
+
self.base_url = base_url.rstrip("/")
|
|
50
|
+
self.on_plan_soft_warning = on_plan_soft_warning
|
|
51
|
+
self._client = httpx.Client(
|
|
52
|
+
base_url=self.base_url,
|
|
53
|
+
timeout=timeout,
|
|
54
|
+
headers={
|
|
55
|
+
"Authorization": f"Bearer {api_key}",
|
|
56
|
+
"Content-Type": "application/json",
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# --- SDK endpoints (agent API key auth) ---
|
|
61
|
+
|
|
62
|
+
def execute(
|
|
63
|
+
self,
|
|
64
|
+
action: str,
|
|
65
|
+
target: str = "",
|
|
66
|
+
params: dict[str, Any] | None = None,
|
|
67
|
+
reasoning_trace: str = "",
|
|
68
|
+
session_id: str = "",
|
|
69
|
+
alternatives: list[dict] | None = None,
|
|
70
|
+
parent_decision_id: str = "",
|
|
71
|
+
) -> ActionResponse:
|
|
72
|
+
"""Submit an action for governance and (on ALLOW) execution.
|
|
73
|
+
|
|
74
|
+
parent_decision_id (A2A, W4.2): when this action is delegated by another
|
|
75
|
+
agent, pass that agent's ALLOW `decision_id` here. Kernel records the
|
|
76
|
+
agent-to-agent delegation chain in the signed audit log — the child
|
|
77
|
+
decision is linked to the parent and inherits the union of their
|
|
78
|
+
regulatory anchors. See `delegate()` for the expressive form.
|
|
79
|
+
"""
|
|
80
|
+
headers: dict[str, str] = {}
|
|
81
|
+
if reasoning_trace:
|
|
82
|
+
headers["X-Reasoning-Trace"] = reasoning_trace
|
|
83
|
+
if session_id:
|
|
84
|
+
headers["X-Session-Id"] = session_id
|
|
85
|
+
if parent_decision_id:
|
|
86
|
+
headers["X-Kernel-Parent-Decision-Id"] = parent_decision_id
|
|
87
|
+
|
|
88
|
+
body: dict[str, Any] = {"action": action}
|
|
89
|
+
if target:
|
|
90
|
+
body["target"] = target
|
|
91
|
+
if params:
|
|
92
|
+
body["params"] = params
|
|
93
|
+
if alternatives:
|
|
94
|
+
body["alternatives"] = alternatives
|
|
95
|
+
|
|
96
|
+
# 202 (pending_approval) is NOT an exception any more (v0.2). We pass
|
|
97
|
+
# `allow_202=True` so the HTTP layer returns the body instead of
|
|
98
|
+
# raising. The caller then branches on `resp.outcome` —
|
|
99
|
+
# KernelOutcome.REQUIRES_HUMAN — to fork into the human-approval flow.
|
|
100
|
+
data = self._request(
|
|
101
|
+
"POST",
|
|
102
|
+
"/v1/agent-action",
|
|
103
|
+
json=body,
|
|
104
|
+
headers=headers,
|
|
105
|
+
allow_202=True,
|
|
106
|
+
)
|
|
107
|
+
return ActionResponse.from_dict(data)
|
|
108
|
+
|
|
109
|
+
def delegate(
|
|
110
|
+
self,
|
|
111
|
+
parent_decision_id: str,
|
|
112
|
+
action: str,
|
|
113
|
+
target: str = "",
|
|
114
|
+
params: dict[str, Any] | None = None,
|
|
115
|
+
reasoning_trace: str = "",
|
|
116
|
+
session_id: str = "",
|
|
117
|
+
alternatives: list[dict] | None = None,
|
|
118
|
+
) -> ActionResponse:
|
|
119
|
+
"""Execute an action delegated by another agent's ALLOW decision (A2A).
|
|
120
|
+
|
|
121
|
+
Reads like the call site it documents:
|
|
122
|
+
|
|
123
|
+
decision = orchestrator.execute("triage_refund", params=…)
|
|
124
|
+
kyc.delegate(decision.decision_id, "verify_identity", params=…)
|
|
125
|
+
|
|
126
|
+
Thin wrapper over `execute(..., parent_decision_id=…)` — Kernel stamps
|
|
127
|
+
the parent linkage (parent_audit_id + parent_agent_id + delegation_depth)
|
|
128
|
+
on the child's decision row and unions the regulatory anchors, so the
|
|
129
|
+
whole delegation chain is queryable end-to-end (GET /api/audit/chain).
|
|
130
|
+
A cross-tenant or unknown parent is refused identically to a missing one
|
|
131
|
+
(raises the same denial — no existence fingerprinting).
|
|
132
|
+
"""
|
|
133
|
+
if not parent_decision_id:
|
|
134
|
+
raise ValueError(
|
|
135
|
+
"delegate() requires parent_decision_id (the delegating agent's "
|
|
136
|
+
"ALLOW decision_id); use execute() for a root action"
|
|
137
|
+
)
|
|
138
|
+
return self.execute(
|
|
139
|
+
action,
|
|
140
|
+
target=target,
|
|
141
|
+
params=params,
|
|
142
|
+
reasoning_trace=reasoning_trace,
|
|
143
|
+
session_id=session_id,
|
|
144
|
+
alternatives=alternatives,
|
|
145
|
+
parent_decision_id=parent_decision_id,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def create_session(self, intent: str) -> Session:
|
|
149
|
+
data = self._request("POST", "/v1/sessions", json={"intent": intent})
|
|
150
|
+
return Session.from_dict(data)
|
|
151
|
+
|
|
152
|
+
def request_token(
|
|
153
|
+
self,
|
|
154
|
+
session_id: str,
|
|
155
|
+
action: str,
|
|
156
|
+
target: str = "",
|
|
157
|
+
params: dict[str, Any] | None = None,
|
|
158
|
+
) -> TokenResponse:
|
|
159
|
+
body: dict[str, Any] = {
|
|
160
|
+
"session_id": session_id,
|
|
161
|
+
"action": action,
|
|
162
|
+
}
|
|
163
|
+
if target:
|
|
164
|
+
body["target"] = target
|
|
165
|
+
if params:
|
|
166
|
+
body["params"] = params
|
|
167
|
+
|
|
168
|
+
data = self._request("POST", "/v1/tokens", json=body)
|
|
169
|
+
return TokenResponse.from_dict(data)
|
|
170
|
+
|
|
171
|
+
def execute_token(self, token_id: str, agent_id: str) -> ExecuteResponse:
|
|
172
|
+
headers = {
|
|
173
|
+
"X-Token-ID": token_id,
|
|
174
|
+
"X-Agent-ID": agent_id,
|
|
175
|
+
}
|
|
176
|
+
data = self._request("POST", "/v1/execute", headers=headers)
|
|
177
|
+
return ExecuteResponse.from_dict(data)
|
|
178
|
+
|
|
179
|
+
def poll_approval(
|
|
180
|
+
self,
|
|
181
|
+
approval_id: str,
|
|
182
|
+
timeout: float = 300,
|
|
183
|
+
interval: float = 2.0,
|
|
184
|
+
) -> ApprovalResult:
|
|
185
|
+
deadline = time.monotonic() + timeout
|
|
186
|
+
while time.monotonic() < deadline:
|
|
187
|
+
resp = self._client.get(f"/v1/approvals/{approval_id}")
|
|
188
|
+
data = resp.json()
|
|
189
|
+
status = data.get("status", "")
|
|
190
|
+
if status != "pending":
|
|
191
|
+
return ApprovalResult.from_dict(data)
|
|
192
|
+
time.sleep(interval)
|
|
193
|
+
raise TimeoutError(f"Approval {approval_id} still pending after {timeout}s")
|
|
194
|
+
|
|
195
|
+
# --- Dashboard endpoints (JWT auth, not agent key) ---
|
|
196
|
+
|
|
197
|
+
def register_key(self, key_id: str, public_key_pem: str) -> dict:
|
|
198
|
+
return self._request(
|
|
199
|
+
"POST",
|
|
200
|
+
"/api/customers/keys",
|
|
201
|
+
json={"key_id": key_id, "public_key_pem": public_key_pem},
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# --- Lifecycle ---
|
|
205
|
+
|
|
206
|
+
def close(self) -> None:
|
|
207
|
+
self._client.close()
|
|
208
|
+
|
|
209
|
+
def __enter__(self) -> Kernel:
|
|
210
|
+
return self
|
|
211
|
+
|
|
212
|
+
def __exit__(self, *args: Any) -> None:
|
|
213
|
+
self.close()
|
|
214
|
+
|
|
215
|
+
# --- Internal ---
|
|
216
|
+
|
|
217
|
+
def _request(
|
|
218
|
+
self,
|
|
219
|
+
method: str,
|
|
220
|
+
path: str,
|
|
221
|
+
**kwargs: Any,
|
|
222
|
+
) -> dict:
|
|
223
|
+
extra_headers = kwargs.pop("headers", None)
|
|
224
|
+
allow_202 = kwargs.pop("allow_202", False)
|
|
225
|
+
if extra_headers:
|
|
226
|
+
merged = dict(self._client.headers)
|
|
227
|
+
merged.update(extra_headers)
|
|
228
|
+
kwargs["headers"] = merged
|
|
229
|
+
|
|
230
|
+
resp = self._client.request(method, path, **kwargs)
|
|
231
|
+
body = resp.json() if resp.content else {}
|
|
232
|
+
|
|
233
|
+
# Structured-error envelope (pricing spec §2.4): if the body carries
|
|
234
|
+
# an `error.code` field, map it to a typed exception first. Falls
|
|
235
|
+
# through to legacy {reason, status} handling for older proxies and
|
|
236
|
+
# for endpoints that haven't migrated yet.
|
|
237
|
+
if 400 <= resp.status_code < 600:
|
|
238
|
+
structured = _errors.from_response(resp.status_code, body)
|
|
239
|
+
if structured is not None:
|
|
240
|
+
raise structured
|
|
241
|
+
|
|
242
|
+
if resp.status_code == 202:
|
|
243
|
+
if allow_202:
|
|
244
|
+
# Normalize the 202 body so ActionResponse.from_dict sees the
|
|
245
|
+
# canonical "pending_approval" status — older proxies may emit
|
|
246
|
+
# `{"approval_id": "..."}` with status empty.
|
|
247
|
+
if isinstance(body, dict) and not body.get("status"):
|
|
248
|
+
body["status"] = "pending_approval"
|
|
249
|
+
return body
|
|
250
|
+
# Legacy path (kept for non-execute endpoints, defensive).
|
|
251
|
+
from kernel.errors import ApprovalRequiredError as _AR
|
|
252
|
+
raise _AR(202, "Human approval required", body)
|
|
253
|
+
if resp.status_code == 401:
|
|
254
|
+
raise AuthError(401, body.get("error", "Authentication failed"), body)
|
|
255
|
+
if resp.status_code == 403:
|
|
256
|
+
# Sprint 04 SDK polish: 403 dispatches to ScannerBlockedError
|
|
257
|
+
# (violations populated) or PolicyDeniedError (empty violations)
|
|
258
|
+
# via the shared base KernelDeniedError. Existing
|
|
259
|
+
# `except PolicyDeniedError:` still catches both in v0.x because
|
|
260
|
+
# both inherit from KernelDeniedError; aliasing is documented in
|
|
261
|
+
# the CHANGELOG.
|
|
262
|
+
raise _build_denied(403, body.get("reason", "Policy denied"), body)
|
|
263
|
+
if resp.status_code == 429:
|
|
264
|
+
raise RateLimitError(429, body.get("reason", "Rate limit exceeded"), body)
|
|
265
|
+
if resp.status_code == 503:
|
|
266
|
+
raise CircuitOpenError(503, body.get("reason", "Circuit breaker open"), body)
|
|
267
|
+
if resp.status_code >= 500:
|
|
268
|
+
raise WebhookError(resp.status_code, body.get("reason", "Server error"), body)
|
|
269
|
+
if resp.status_code >= 400:
|
|
270
|
+
raise KernelError(resp.status_code, body.get("reason", resp.text), body)
|
|
271
|
+
|
|
272
|
+
# Soft warning: the request succeeded but the proxy is telling us the
|
|
273
|
+
# workspace crossed a soft threshold. Fires the callback (if any) and
|
|
274
|
+
# strips the `warning` block from the returned body so downstream
|
|
275
|
+
# handler code doesn't have to know about it.
|
|
276
|
+
if self.on_plan_soft_warning is not None:
|
|
277
|
+
warning_block = body.get("warning") if isinstance(body, dict) else None
|
|
278
|
+
if isinstance(warning_block, dict):
|
|
279
|
+
import contextlib
|
|
280
|
+
|
|
281
|
+
# Customer callback raised — never let it break the request.
|
|
282
|
+
with contextlib.suppress(Exception):
|
|
283
|
+
self.on_plan_soft_warning(PlanSoftWarning(warning_block))
|
|
284
|
+
|
|
285
|
+
return body
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Envelope encryption utilities for decrypting Kernel responses."""
|
|
2
|
+
|
|
3
|
+
from kernel.crypto.decrypt import decrypt, decrypt_json
|
|
4
|
+
from kernel.crypto.keys import export_public_key_pem, generate_key_pair, load_private_key
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"decrypt",
|
|
8
|
+
"decrypt_json",
|
|
9
|
+
"generate_key_pair",
|
|
10
|
+
"export_public_key_pem",
|
|
11
|
+
"load_private_key",
|
|
12
|
+
]
|
kernel/crypto/decrypt.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
8
|
+
from cryptography.hazmat.primitives.asymmetric import padding
|
|
9
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def decrypt(payload: dict | Any, private_key: Any) -> bytes:
|
|
13
|
+
if isinstance(private_key, (str, bytes)):
|
|
14
|
+
private_key = _load_key(private_key)
|
|
15
|
+
|
|
16
|
+
ciphertext = base64.b64decode(payload["ciphertext"])
|
|
17
|
+
wrapped_dek = base64.b64decode(payload["wrapped_dek"])
|
|
18
|
+
nonce = base64.b64decode(payload["nonce"])
|
|
19
|
+
|
|
20
|
+
dek = private_key.decrypt(
|
|
21
|
+
wrapped_dek,
|
|
22
|
+
padding.OAEP(
|
|
23
|
+
mgf=padding.MGF1(algorithm=hashes.SHA256()),
|
|
24
|
+
algorithm=hashes.SHA256(),
|
|
25
|
+
label=None,
|
|
26
|
+
),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
return AESGCM(dek).decrypt(nonce, ciphertext, None)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def decrypt_json(payload: dict | Any, private_key: Any) -> dict:
|
|
33
|
+
return json.loads(decrypt(payload, private_key))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _load_key(key_data: str | bytes) -> Any:
|
|
37
|
+
if isinstance(key_data, str):
|
|
38
|
+
key_data = key_data.encode()
|
|
39
|
+
return serialization.load_pem_private_key(key_data, password=None)
|
kernel/crypto/keys.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from cryptography.hazmat.primitives import serialization
|
|
7
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def generate_key_pair(key_size: int = 2048) -> tuple[Any, Any]:
|
|
11
|
+
private_key = rsa.generate_private_key(public_exponent=65537, key_size=key_size)
|
|
12
|
+
return private_key, private_key.public_key()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def export_public_key_pem(public_key: Any) -> str:
|
|
16
|
+
return public_key.public_bytes(
|
|
17
|
+
encoding=serialization.Encoding.PEM,
|
|
18
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
19
|
+
).decode()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_private_key(path_or_pem: str | Path) -> Any:
|
|
23
|
+
if isinstance(path_or_pem, str) and not path_or_pem.startswith("-----"):
|
|
24
|
+
p = Path(path_or_pem)
|
|
25
|
+
data = p.read_bytes()
|
|
26
|
+
elif isinstance(path_or_pem, Path):
|
|
27
|
+
data = path_or_pem.read_bytes()
|
|
28
|
+
else:
|
|
29
|
+
data = path_or_pem.encode() if isinstance(path_or_pem, str) else path_or_pem
|
|
30
|
+
return serialization.load_pem_private_key(data, password=None)
|
kernel/errors.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import warnings
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class KernelError(Exception):
|
|
8
|
+
def __init__(self, status_code: int, message: str, body: dict | None = None):
|
|
9
|
+
self.status_code = status_code
|
|
10
|
+
self.message = message
|
|
11
|
+
self.body = body or {}
|
|
12
|
+
super().__init__(f"[{status_code}] {message}")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AuthError(KernelError):
|
|
16
|
+
"""401 — invalid or missing API key / JWT."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# 403 — denied family
|
|
21
|
+
#
|
|
22
|
+
# Sprint 04 SDK polish: today consumers inspect `e.violations` truthiness to
|
|
23
|
+
# distinguish scanner blocks from policy denies. That's a runtime sniff over
|
|
24
|
+
# what should be a type-system distinction. We split the surface into two
|
|
25
|
+
# sibling classes sharing a common base so existing
|
|
26
|
+
#
|
|
27
|
+
# except PolicyDeniedError:
|
|
28
|
+
#
|
|
29
|
+
# keeps catching the same set of cases (the base IS PolicyDeniedError for
|
|
30
|
+
# back-compat in v0.x), while new code can write:
|
|
31
|
+
#
|
|
32
|
+
# except ScannerBlockedError as e: e.violation_type ...
|
|
33
|
+
# except PolicyDeniedError as e: e.rule_id, e.policy_id ...
|
|
34
|
+
#
|
|
35
|
+
# The base class is exported as `KernelDeniedError` for callers who want to
|
|
36
|
+
# catch both explicitly without subscribing to the back-compat name.
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PolicyDeniedError(KernelError):
|
|
41
|
+
"""403 — a policy or scanner denied the action.
|
|
42
|
+
|
|
43
|
+
In v0.1, this was the SOLE 403 exception, with consumers inspecting
|
|
44
|
+
`e.violations` truthiness to distinguish scanner blocks from policy
|
|
45
|
+
denies. In v0.2 we introduce the sibling `ScannerBlockedError` so the
|
|
46
|
+
distinction is in the type system, not in field inspection.
|
|
47
|
+
|
|
48
|
+
Back-compat: `ScannerBlockedError` inherits from `PolicyDeniedError`
|
|
49
|
+
for the v0.2.x line, so existing `except PolicyDeniedError:` blocks
|
|
50
|
+
keep catching both. In v0.3, `ScannerBlockedError` will become a true
|
|
51
|
+
sibling under the shared `KernelDeniedError` base (and catching the
|
|
52
|
+
base by name will be the recommended pattern when both should be
|
|
53
|
+
handled). Use `isinstance(e, ScannerBlockedError)` (or simply
|
|
54
|
+
`except ScannerBlockedError:`) to start the migration now.
|
|
55
|
+
|
|
56
|
+
Attributes:
|
|
57
|
+
violations — list of scanner violation dicts (populated only on
|
|
58
|
+
ScannerBlockedError; empty/None on the pure policy
|
|
59
|
+
path)
|
|
60
|
+
reason — human-readable proxy explanation
|
|
61
|
+
rule_id — policy rule that denied, if surfaced (policy path)
|
|
62
|
+
policy_id — owning policy id, if surfaced (policy path)
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, status_code: int, message: str, body: dict | None = None):
|
|
66
|
+
super().__init__(status_code, message, body)
|
|
67
|
+
body = body or {}
|
|
68
|
+
self.violations = body.get("violations")
|
|
69
|
+
self.reason = body.get("reason", "")
|
|
70
|
+
rationale = body.get("rationale") if isinstance(body, dict) else None
|
|
71
|
+
if isinstance(rationale, dict):
|
|
72
|
+
self.rule_id: str = rationale.get("rule_id", "") or rationale.get(
|
|
73
|
+
"control_id", ""
|
|
74
|
+
)
|
|
75
|
+
self.policy_id: str = rationale.get("policy_id", "")
|
|
76
|
+
else:
|
|
77
|
+
self.rule_id = body.get("rule_id", "")
|
|
78
|
+
self.policy_id = body.get("policy_id", "")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# Alias for callers who want to catch the whole 403-denial family by a name
|
|
82
|
+
# that doesn't say "Policy". In v0.3 this becomes the actual base class and
|
|
83
|
+
# `ScannerBlockedError` becomes a true sibling under it. Keeping it as an
|
|
84
|
+
# alias in v0.2 means `except KernelDeniedError:` is forward-compatible.
|
|
85
|
+
KernelDeniedError = PolicyDeniedError
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ScannerBlockedError(PolicyDeniedError):
|
|
89
|
+
"""403 — a scanner layer fired (PII, secrets, prompt injection, …).
|
|
90
|
+
|
|
91
|
+
Distinguishing signal: the response body carries a non-empty `violations`
|
|
92
|
+
list. The first violation drives the `violation_type` and
|
|
93
|
+
`violation_details` convenience attributes; access `violations` for the
|
|
94
|
+
full list when more than one fired.
|
|
95
|
+
|
|
96
|
+
In v0.2, this inherits from `PolicyDeniedError` so v0.1 code that catches
|
|
97
|
+
`PolicyDeniedError` keeps working. In v0.3 the two will become true
|
|
98
|
+
siblings under `KernelDeniedError`; migrate by catching them separately.
|
|
99
|
+
|
|
100
|
+
Attributes:
|
|
101
|
+
violation_type — first violation's `type` (e.g. "pii.ssn",
|
|
102
|
+
"secret.aws_key", "prompt_injection")
|
|
103
|
+
violation_details — first violation's full dict
|
|
104
|
+
violations — list of all violation dicts (back-compat)
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(self, status_code: int, message: str, body: dict | None = None):
|
|
108
|
+
super().__init__(status_code, message, body)
|
|
109
|
+
violations = self.violations or []
|
|
110
|
+
first: dict[str, Any] = (
|
|
111
|
+
violations[0] if violations and isinstance(violations[0], dict) else {}
|
|
112
|
+
)
|
|
113
|
+
self.violation_type: str = first.get("type", "")
|
|
114
|
+
self.violation_details: dict[str, Any] = first
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _build_denied(status_code: int, message: str, body: dict | None) -> KernelDeniedError:
|
|
118
|
+
"""Dispatch 403 bodies to the right sibling based on `violations`.
|
|
119
|
+
|
|
120
|
+
Used by the HTTP layer; also re-used by tests. Encapsulates the
|
|
121
|
+
"violations truthiness → ScannerBlockedError, else PolicyDeniedError"
|
|
122
|
+
rule so the dispatch logic lives in exactly one place.
|
|
123
|
+
"""
|
|
124
|
+
violations = (body or {}).get("violations") if body else None
|
|
125
|
+
if violations:
|
|
126
|
+
return ScannerBlockedError(status_code, message, body)
|
|
127
|
+
return PolicyDeniedError(status_code, message, body)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class RateLimitError(KernelError):
|
|
131
|
+
"""429 — control group rate/spend limit exceeded."""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class CircuitOpenError(KernelError):
|
|
135
|
+
"""503 — circuit breaker is open for this agent."""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class ApprovalRequiredError(KernelError):
|
|
139
|
+
"""DEPRECATED — 202 responses no longer raise.
|
|
140
|
+
|
|
141
|
+
In v0.1.x, a 202 response (action escalated to human-in-the-loop)
|
|
142
|
+
raised this exception. Starting in v0.2, the SDK returns an
|
|
143
|
+
`ActionResponse` with `outcome == KernelOutcome.REQUIRES_HUMAN` so
|
|
144
|
+
callers can use `match/case` on `resp.outcome` instead of mixing a
|
|
145
|
+
happy-ish path into the except chain. See `KernelOutcome` in
|
|
146
|
+
`kernel.types`.
|
|
147
|
+
|
|
148
|
+
This class is retained as a deprecated alias for one minor version so
|
|
149
|
+
existing `except ApprovalRequiredError:` blocks keep importing. It is
|
|
150
|
+
NEVER raised by the SDK in v0.2+. Importing it emits a
|
|
151
|
+
`DeprecationWarning`; remove your `except ApprovalRequiredError` block
|
|
152
|
+
and branch on `resp.outcome` instead.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
def __init__(self, status_code: int, message: str, body: dict | None = None):
|
|
156
|
+
super().__init__(status_code, message, body)
|
|
157
|
+
self.approval_id: str = (body or {}).get("approval_id", "")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def __getattr__(name: str) -> Any:
|
|
161
|
+
# Lazy deprecation warning: only fires when someone actually imports
|
|
162
|
+
# ApprovalRequiredError from kernel.errors. We can't put the warning in
|
|
163
|
+
# the class body because `from kernel.errors import ApprovalRequiredError`
|
|
164
|
+
# at the top of the module doesn't trigger __getattr__; this hook fires
|
|
165
|
+
# when something accesses the attribute via getattr or a delayed import,
|
|
166
|
+
# and `kernel.__init__` re-routes the canonical re-export through here.
|
|
167
|
+
if name == "_ApprovalRequiredError_deprecation_check":
|
|
168
|
+
warnings.warn(
|
|
169
|
+
"ApprovalRequiredError is deprecated and will be removed in v0.3. "
|
|
170
|
+
"The SDK no longer raises on 202 responses; branch on "
|
|
171
|
+
"ActionResponse.outcome (KernelOutcome.REQUIRES_HUMAN) instead. "
|
|
172
|
+
"See CHANGELOG for migration.",
|
|
173
|
+
DeprecationWarning,
|
|
174
|
+
stacklevel=3,
|
|
175
|
+
)
|
|
176
|
+
return ApprovalRequiredError
|
|
177
|
+
raise AttributeError(name)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class WebhookError(KernelError):
|
|
181
|
+
"""500 — webhook call to customer backend failed."""
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
# Pricing / plan limit errors — Pricing spec §2.4 structured envelope.
|
|
186
|
+
# The Go proxy returns a body shaped like
|
|
187
|
+
# {"error": {"code": "plan_limit_exceeded", "limit": "actions", ...}}
|
|
188
|
+
# These classes pull the fields out so callers can route on
|
|
189
|
+
# except PlanLimitExceeded as e: ... route_to_billing(e.reset_at, e.tier)
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class _StructuredError(KernelError):
|
|
194
|
+
"""Internal base for spec-§2.4 errors: pulls the `error` sub-object out of
|
|
195
|
+
the body so subclasses can index fields directly without re-doing the
|
|
196
|
+
.get('error', {}) dance."""
|
|
197
|
+
|
|
198
|
+
def __init__(self, status_code: int, message: str, body: dict | None = None):
|
|
199
|
+
super().__init__(status_code, message, body)
|
|
200
|
+
self.error: dict = (body or {}).get("error", {})
|
|
201
|
+
self.code: str = self.error.get("code", "")
|
|
202
|
+
self.upgrade_url: str = self.error.get("upgrade_url", "")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class PlanLimitExceeded(_StructuredError):
|
|
206
|
+
"""402 — monthly action / ai_drafts quota or count limit hit.
|
|
207
|
+
|
|
208
|
+
Sales-team signal: this typically maps to an "upgrade to Pro" prompt.
|
|
209
|
+
Pair with PlanCeilingHit (which is the upgrade-to-Enterprise signal).
|
|
210
|
+
|
|
211
|
+
Attributes carry the spec-§2.4 fields:
|
|
212
|
+
limit – which dimension hit ("actions", "agents", "seats", …)
|
|
213
|
+
tier – the customer's current tier
|
|
214
|
+
current – the counter value at denial
|
|
215
|
+
cap – the cap that was hit
|
|
216
|
+
reset_at – RFC3339 timestamp for monthly quotas; empty for count limits
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
def __init__(self, status_code: int, message: str, body: dict | None = None):
|
|
220
|
+
super().__init__(status_code, message, body)
|
|
221
|
+
self.limit: str = self.error.get("limit", "")
|
|
222
|
+
self.tier: str = self.error.get("tier", "")
|
|
223
|
+
self.current: int = self.error.get("current", 0)
|
|
224
|
+
self.cap: int = self.error.get("cap", 0)
|
|
225
|
+
self.reset_at: str = self.error.get("reset_at", "")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class PlanCeilingHit(PlanLimitExceeded):
|
|
229
|
+
"""402 — Pro tier hit the hard ceiling (e.g. 150k actions/mo).
|
|
230
|
+
|
|
231
|
+
Distinct class from PlanLimitExceeded so customers can route the
|
|
232
|
+
upgrade-to-Enterprise signal differently from the upgrade-to-Pro signal.
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class FeatureNotInTier(_StructuredError):
|
|
237
|
+
"""400 — feature gated to a higher tier (BYOK, SSO, tamper-evident, etc).
|
|
238
|
+
|
|
239
|
+
Attributes:
|
|
240
|
+
feature – machine-readable feature name ("byok_encryption", …)
|
|
241
|
+
current_tier – customer's current tier
|
|
242
|
+
required_tier – the tier where this feature unlocks
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
def __init__(self, status_code: int, message: str, body: dict | None = None):
|
|
246
|
+
super().__init__(status_code, message, body)
|
|
247
|
+
self.feature: str = self.error.get("feature", "")
|
|
248
|
+
self.current_tier: str = self.error.get("current_tier", "")
|
|
249
|
+
self.required_tier: str = self.error.get("required_tier", "")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class PlanSoftWarning:
|
|
253
|
+
"""NOT an exception — payload of the on_plan_soft_warning callback.
|
|
254
|
+
|
|
255
|
+
When the proxy crosses the soft-warning threshold (Pro at 100k actions,
|
|
256
|
+
Sandbox at 4k), it returns the normal result PLUS:
|
|
257
|
+
- a RFC 7234 `Warning:` HTTP header
|
|
258
|
+
- a `warning` block in the JSON body alongside `result`
|
|
259
|
+
The SDK invokes the customer's optional on_plan_soft_warning(warning)
|
|
260
|
+
callback with one of these. The request still succeeded.
|
|
261
|
+
|
|
262
|
+
Attributes mirror the spec-§2.4 warning body:
|
|
263
|
+
limit – "actions" | "ai_drafts"
|
|
264
|
+
current – counter value after this increment
|
|
265
|
+
cap – soft warning threshold that was crossed
|
|
266
|
+
message – pre-rendered human-readable text
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
def __init__(self, body: dict):
|
|
270
|
+
self.code: str = body.get("code", "")
|
|
271
|
+
self.limit: str = body.get("limit", "")
|
|
272
|
+
self.current: int = body.get("current", 0)
|
|
273
|
+
self.cap: int = body.get("cap", 0)
|
|
274
|
+
self.message: str = body.get("message", "")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def from_response(status_code: int, body: dict | None) -> KernelError | None:
|
|
278
|
+
"""Map a structured-error response body to the right typed exception.
|
|
279
|
+
|
|
280
|
+
Returns None if the body doesn't carry an `error.code` field (caller
|
|
281
|
+
should fall back to legacy parsing or generic KernelError).
|
|
282
|
+
|
|
283
|
+
Use this in the HTTP-call layer:
|
|
284
|
+
|
|
285
|
+
if 400 <= resp.status_code < 600:
|
|
286
|
+
err = errors.from_response(resp.status_code, resp.json())
|
|
287
|
+
if err is not None:
|
|
288
|
+
raise err
|
|
289
|
+
# fall through to legacy {status, reason} body handling
|
|
290
|
+
"""
|
|
291
|
+
if not body:
|
|
292
|
+
return None
|
|
293
|
+
err_block = body.get("error")
|
|
294
|
+
if not isinstance(err_block, dict):
|
|
295
|
+
return None
|
|
296
|
+
code = err_block.get("code", "")
|
|
297
|
+
message = err_block.get("message", "")
|
|
298
|
+
if code == "plan_limit_exceeded":
|
|
299
|
+
return PlanLimitExceeded(status_code, message, body)
|
|
300
|
+
if code == "plan_ceiling_hit":
|
|
301
|
+
return PlanCeilingHit(status_code, message, body)
|
|
302
|
+
if code == "feature_not_in_tier":
|
|
303
|
+
return FeatureNotInTier(status_code, message, body)
|
|
304
|
+
return None
|
kernel/py.typed
ADDED
|
File without changes
|
kernel/types.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import warnings
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class KernelOutcome(str, Enum):
|
|
9
|
+
"""The four control-flow outcomes of `Kernel.execute`.
|
|
10
|
+
|
|
11
|
+
Designed so callers can write:
|
|
12
|
+
|
|
13
|
+
resp = k.execute(...)
|
|
14
|
+
match resp.outcome:
|
|
15
|
+
case KernelOutcome.ALLOWED: ...
|
|
16
|
+
case KernelOutcome.REQUIRES_HUMAN: ...
|
|
17
|
+
case KernelOutcome.BLOCKED: ...
|
|
18
|
+
case KernelOutcome.DENIED: ...
|
|
19
|
+
|
|
20
|
+
instead of mixing a happy-ish "ask a human" path into the except chain.
|
|
21
|
+
|
|
22
|
+
Outcome mapping from the proxy status field:
|
|
23
|
+
"executed" → ALLOWED
|
|
24
|
+
"pending_approval" → REQUIRES_HUMAN (HTTP 202)
|
|
25
|
+
"blocked" → BLOCKED (a scanner fired; was also `ScannerBlockedError`)
|
|
26
|
+
"denied" → DENIED (a policy rule fired; was also `PolicyDeniedError`)
|
|
27
|
+
|
|
28
|
+
Note: BLOCKED and DENIED outcomes are emitted ONLY when the proxy returns
|
|
29
|
+
them on a 2xx response (rare today — most proxies still return 403). The
|
|
30
|
+
HTTP-error path still raises `ScannerBlockedError` / `PolicyDeniedError`;
|
|
31
|
+
`match` on `outcome` is the unified surface once the proxy converges.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
ALLOWED = "allowed"
|
|
35
|
+
REQUIRES_HUMAN = "requires_human"
|
|
36
|
+
BLOCKED = "blocked"
|
|
37
|
+
DENIED = "denied"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _derive_result_message(
|
|
41
|
+
explicit: str | None,
|
|
42
|
+
status: str,
|
|
43
|
+
result: dict | None,
|
|
44
|
+
reason: str | None,
|
|
45
|
+
approval_id: str | None,
|
|
46
|
+
) -> str:
|
|
47
|
+
"""Synthesize a human-readable summary when the proxy doesn't send one.
|
|
48
|
+
|
|
49
|
+
The Kernel proxy is expected to emit `result_message` (or legacy
|
|
50
|
+
`message`) directly. Until every proxy version does so reliably, we
|
|
51
|
+
derive a short summary from what's available — same intent, no new
|
|
52
|
+
backend dependency. LLMs reason better over a non-empty string than
|
|
53
|
+
over a None result.
|
|
54
|
+
"""
|
|
55
|
+
if explicit:
|
|
56
|
+
return explicit
|
|
57
|
+
if status == "pending_approval" and approval_id:
|
|
58
|
+
return f"Action escalated to human approval (approval_id={approval_id})."
|
|
59
|
+
if status == "blocked":
|
|
60
|
+
return reason or "Action blocked by a scanner."
|
|
61
|
+
if status == "denied":
|
|
62
|
+
return reason or "Action denied by policy."
|
|
63
|
+
if status == "executed":
|
|
64
|
+
if isinstance(result, dict) and result:
|
|
65
|
+
keys = ", ".join(sorted(result.keys())[:3])
|
|
66
|
+
return f"Action executed; result contains: {keys}."
|
|
67
|
+
return "Action executed."
|
|
68
|
+
return reason or "Action completed."
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
_STATUS_TO_OUTCOME: dict[str, KernelOutcome] = {
|
|
72
|
+
"executed": KernelOutcome.ALLOWED,
|
|
73
|
+
"pending_approval": KernelOutcome.REQUIRES_HUMAN,
|
|
74
|
+
"blocked": KernelOutcome.BLOCKED,
|
|
75
|
+
"denied": KernelOutcome.DENIED,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class ActionResponse:
|
|
81
|
+
"""Result of a single `Kernel.execute` call.
|
|
82
|
+
|
|
83
|
+
Sprint 04 polish (v0.2):
|
|
84
|
+
- `outcome` is the canonical control-flow surface; branch on this
|
|
85
|
+
instead of catching `ApprovalRequiredError`. See `KernelOutcome`.
|
|
86
|
+
- `result_message` is always non-empty — either the proxy's own
|
|
87
|
+
summary or a derived one (see `_derive_result_message`). Avoids
|
|
88
|
+
the LLM-anthropomorphizes-None problem when `result is None`.
|
|
89
|
+
- `message` is retained as a deprecated alias for `result_message`.
|
|
90
|
+
Reading `.message` emits a `DeprecationWarning`.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
status: str
|
|
94
|
+
outcome: KernelOutcome = KernelOutcome.ALLOWED
|
|
95
|
+
token_id: str | None = None
|
|
96
|
+
result: dict | None = None
|
|
97
|
+
result_message: str = ""
|
|
98
|
+
approval_id: str | None = None
|
|
99
|
+
reason: str | None = None
|
|
100
|
+
violations: list | None = None
|
|
101
|
+
# decision_id (Sprint 04, GOV-V-01 + A2A W4.2) — the audit-event id of the
|
|
102
|
+
# ALLOW decision. Pass it as `parent_decision_id` to a downstream agent's
|
|
103
|
+
# `delegate()` to record the agent-to-agent delegation chain. None on
|
|
104
|
+
# non-ALLOW outcomes and from proxies predating execution verification.
|
|
105
|
+
decision_id: str | None = None
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def message(self) -> str:
|
|
109
|
+
"""DEPRECATED — use `result_message`. Removed in v0.3."""
|
|
110
|
+
warnings.warn(
|
|
111
|
+
"ActionResponse.message is deprecated; use .result_message instead. "
|
|
112
|
+
"Will be removed in v0.3.",
|
|
113
|
+
DeprecationWarning,
|
|
114
|
+
stacklevel=2,
|
|
115
|
+
)
|
|
116
|
+
return self.result_message
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def encrypted(self) -> bool:
|
|
120
|
+
return isinstance(self.result, dict) and self.result.get("encrypted") is True
|
|
121
|
+
|
|
122
|
+
def decrypt(self, private_key) -> dict:
|
|
123
|
+
if not self.encrypted:
|
|
124
|
+
return self.result or {}
|
|
125
|
+
try:
|
|
126
|
+
from kernel.crypto import decrypt_json
|
|
127
|
+
except ImportError as exc:
|
|
128
|
+
raise ImportError(
|
|
129
|
+
"Install the crypto extra: pip install onkernel[crypto]"
|
|
130
|
+
) from exc
|
|
131
|
+
return decrypt_json(self.result, private_key)
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def from_dict(cls, data: dict) -> ActionResponse:
|
|
135
|
+
status = data.get("status", "")
|
|
136
|
+
result = data.get("result")
|
|
137
|
+
reason = data.get("reason")
|
|
138
|
+
approval_id = data.get("approval_id")
|
|
139
|
+
# Backend currently emits `message`; spec calls for `result_message`.
|
|
140
|
+
# Accept either; prefer the spec name. Derived fallback if neither.
|
|
141
|
+
explicit_message = data.get("result_message") or data.get("message")
|
|
142
|
+
result_message = _derive_result_message(
|
|
143
|
+
explicit_message, status, result, reason, approval_id
|
|
144
|
+
)
|
|
145
|
+
outcome = _STATUS_TO_OUTCOME.get(status, KernelOutcome.ALLOWED)
|
|
146
|
+
if not explicit_message and status in _STATUS_TO_OUTCOME:
|
|
147
|
+
# Quiet hint so customers can flag stale proxies. Not a deprecation
|
|
148
|
+
# warning — the proxy is the one behind, not the caller — but a
|
|
149
|
+
# one-shot log via `warnings` is the lightest tool that's visible
|
|
150
|
+
# to anyone who runs `python -W default`.
|
|
151
|
+
warnings.warn(
|
|
152
|
+
"Proxy response did not include `result_message`; SDK derived "
|
|
153
|
+
"one from status/result. Upgrade the Kernel proxy for richer "
|
|
154
|
+
"summaries.",
|
|
155
|
+
category=UserWarning,
|
|
156
|
+
stacklevel=4,
|
|
157
|
+
)
|
|
158
|
+
return cls(
|
|
159
|
+
status=status,
|
|
160
|
+
outcome=outcome,
|
|
161
|
+
token_id=data.get("token_id"),
|
|
162
|
+
result=result,
|
|
163
|
+
result_message=result_message,
|
|
164
|
+
approval_id=approval_id,
|
|
165
|
+
reason=reason,
|
|
166
|
+
violations=data.get("violations"),
|
|
167
|
+
decision_id=data.get("decision_id"),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass
|
|
172
|
+
class Session:
|
|
173
|
+
id: str
|
|
174
|
+
agent_id: str
|
|
175
|
+
intent: str
|
|
176
|
+
expires_at: str
|
|
177
|
+
|
|
178
|
+
@classmethod
|
|
179
|
+
def from_dict(cls, data: dict) -> Session:
|
|
180
|
+
return cls(
|
|
181
|
+
id=data.get("id", ""),
|
|
182
|
+
agent_id=data.get("agent_id", ""),
|
|
183
|
+
intent=data.get("intent", ""),
|
|
184
|
+
expires_at=data.get("expires_at", ""),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass
|
|
189
|
+
class TokenResponse:
|
|
190
|
+
token_id: str
|
|
191
|
+
expires_at: str
|
|
192
|
+
scoped_to: dict = field(default_factory=dict)
|
|
193
|
+
status: str = ""
|
|
194
|
+
|
|
195
|
+
@classmethod
|
|
196
|
+
def from_dict(cls, data: dict) -> TokenResponse:
|
|
197
|
+
return cls(
|
|
198
|
+
token_id=data.get("token_id", ""),
|
|
199
|
+
expires_at=data.get("expires_at", ""),
|
|
200
|
+
scoped_to=data.get("scoped_to", {}),
|
|
201
|
+
status=data.get("status", ""),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclass
|
|
206
|
+
class ExecuteResponse:
|
|
207
|
+
status: str
|
|
208
|
+
token_consumed: bool = False
|
|
209
|
+
action: str = ""
|
|
210
|
+
target_result: dict = field(default_factory=dict)
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def encrypted(self) -> bool:
|
|
214
|
+
return isinstance(self.target_result, dict) and self.target_result.get("encrypted") is True
|
|
215
|
+
|
|
216
|
+
def decrypt(self, private_key) -> dict:
|
|
217
|
+
if not self.encrypted:
|
|
218
|
+
return self.target_result
|
|
219
|
+
try:
|
|
220
|
+
from kernel.crypto import decrypt_json
|
|
221
|
+
except ImportError as exc:
|
|
222
|
+
raise ImportError(
|
|
223
|
+
"Install the crypto extra: pip install onkernel[crypto]"
|
|
224
|
+
) from exc
|
|
225
|
+
return decrypt_json(self.target_result, private_key)
|
|
226
|
+
|
|
227
|
+
@classmethod
|
|
228
|
+
def from_dict(cls, data: dict) -> ExecuteResponse:
|
|
229
|
+
return cls(
|
|
230
|
+
status=data.get("status", ""),
|
|
231
|
+
token_consumed=data.get("token_consumed", False),
|
|
232
|
+
action=data.get("action", ""),
|
|
233
|
+
target_result=data.get("target_result", {}),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@dataclass
|
|
238
|
+
class ApprovalResult:
|
|
239
|
+
status: str
|
|
240
|
+
decision: str | None = None
|
|
241
|
+
token_id: str | None = None
|
|
242
|
+
|
|
243
|
+
@classmethod
|
|
244
|
+
def from_dict(cls, data: dict) -> ApprovalResult:
|
|
245
|
+
return cls(
|
|
246
|
+
status=data.get("status", ""),
|
|
247
|
+
decision=data.get("decision"),
|
|
248
|
+
token_id=data.get("token_id"),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@dataclass
|
|
253
|
+
class EncryptedPayload:
|
|
254
|
+
ciphertext: bytes
|
|
255
|
+
wrapped_dek: bytes
|
|
256
|
+
key_id: str
|
|
257
|
+
nonce: bytes
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: onkernel
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python SDK for Kernel — deterministic governance for AI agents
|
|
5
|
+
Project-URL: Homepage, https://onkernel.ai
|
|
6
|
+
Project-URL: Documentation, https://github.com/onkernel-ai/kernel-python#readme
|
|
7
|
+
Project-URL: Repository, https://github.com/onkernel-ai/kernel-python
|
|
8
|
+
Project-URL: Issues, https://github.com/onkernel-ai/kernel-python/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/onkernel-ai/kernel-python/blob/main/CHANGELOG.md
|
|
10
|
+
Author-email: Kernel <engineering@onkernel.ai>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: agents,ai,governance,guardrails,llm,policy
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Requires-Dist: httpx>=0.27
|
|
27
|
+
Provides-Extra: crypto
|
|
28
|
+
Requires-Dist: cryptography>=44.0; extra == 'crypto'
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: cryptography>=44.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
33
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
34
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
35
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# onkernel
|
|
39
|
+
|
|
40
|
+
Python SDK for [Kernel](https://onkernel.ai) — deterministic governance for AI agents.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install onkernel
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
For encrypted response decryption:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install onkernel[crypto]
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Quick Start
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from kernel import Kernel, KernelOutcome
|
|
58
|
+
|
|
59
|
+
k = Kernel(api_key="ok_live_acme_bot1_abc123")
|
|
60
|
+
|
|
61
|
+
resp = k.execute(action="send_email", target="user@example.com", params={"body": "Hello"})
|
|
62
|
+
|
|
63
|
+
match resp.outcome:
|
|
64
|
+
case KernelOutcome.ALLOWED:
|
|
65
|
+
# resp.result and resp.result_message are populated
|
|
66
|
+
print(resp.result_message)
|
|
67
|
+
case KernelOutcome.REQUIRES_HUMAN:
|
|
68
|
+
# 202 — escalate to your approval queue
|
|
69
|
+
approval_id = resp.approval_id
|
|
70
|
+
case KernelOutcome.BLOCKED:
|
|
71
|
+
# a scanner fired; resp.violations is populated
|
|
72
|
+
...
|
|
73
|
+
case KernelOutcome.DENIED:
|
|
74
|
+
# a policy rule denied; resp.reason explains why
|
|
75
|
+
...
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`ActionResponse` always carries a non-empty `result_message: str` — a
|
|
79
|
+
human-readable summary that's safe to surface in an LLM tool-call result.
|
|
80
|
+
|
|
81
|
+
### Handling errors
|
|
82
|
+
|
|
83
|
+
The denial path raises typed exceptions on 403:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from kernel import (
|
|
87
|
+
Kernel,
|
|
88
|
+
ScannerBlockedError, # scanner fired (PII, secrets, prompt injection, …)
|
|
89
|
+
PolicyDeniedError, # policy rule denied with no scanner involvement
|
|
90
|
+
KernelDeniedError, # base / alias — catch both
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
k.execute(action="export_pii_report", target="customers")
|
|
95
|
+
except ScannerBlockedError as e:
|
|
96
|
+
# e.violation_type, e.violation_details, e.violations
|
|
97
|
+
pass
|
|
98
|
+
except PolicyDeniedError as e:
|
|
99
|
+
# e.rule_id, e.policy_id, e.reason
|
|
100
|
+
pass
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### 3-step token flow
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
session = k.create_session(intent="onboard new customer")
|
|
107
|
+
token = k.request_token(session.id, action="create_account", target="stripe")
|
|
108
|
+
result = k.execute_token(token.token_id, agent_id="bot1")
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Decrypting Responses
|
|
112
|
+
|
|
113
|
+
When a policy has `encryption_enabled: true`, responses arrive encrypted:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from kernel import Kernel
|
|
117
|
+
from kernel.crypto import load_private_key
|
|
118
|
+
|
|
119
|
+
k = Kernel(api_key="ok_live_acme_bot1_abc123")
|
|
120
|
+
private_key = load_private_key("path/to/private_key.pem")
|
|
121
|
+
|
|
122
|
+
resp = k.execute(action="fetch_records", target="db")
|
|
123
|
+
if resp.encrypted:
|
|
124
|
+
data = resp.decrypt(private_key)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Publishing (maintainers)
|
|
128
|
+
|
|
129
|
+
The SDK is published to PyPI as `onkernel`. Releases go out via tag push;
|
|
130
|
+
the `publish.yml` workflow handles build + Trusted-Publishing OIDC.
|
|
131
|
+
|
|
132
|
+
**Always release to TestPyPI first.** PyPI is fussy about metadata and
|
|
133
|
+
artifact integrity; a TestPyPI dry-run catches mistakes before they're
|
|
134
|
+
permanent (PyPI does not allow overwriting a released version).
|
|
135
|
+
|
|
136
|
+
### One-time setup
|
|
137
|
+
|
|
138
|
+
1. Create the project on PyPI: `pip install build twine && python -m build && twine upload --repository testpypi dist/*` (first manual upload claims the namespace).
|
|
139
|
+
2. On PyPI → project → Publishing, add a pending publisher:
|
|
140
|
+
- Owner: `onkernel-ai`
|
|
141
|
+
- Repository: `kernel-python`
|
|
142
|
+
- Workflow: `publish.yml`
|
|
143
|
+
- Environment: `pypi`
|
|
144
|
+
3. Repeat the publisher binding on test.pypi.org with environment `testpypi`.
|
|
145
|
+
4. In the GitHub repo Settings → Environments, create `pypi` and `testpypi`. Add required reviewers on `pypi` if you want a release gate.
|
|
146
|
+
|
|
147
|
+
### Cutting a release
|
|
148
|
+
|
|
149
|
+
1. Bump `pyproject.toml` `[project].version` and `src/kernel/__init__.py` `__version__` together.
|
|
150
|
+
2. Update `CHANGELOG.md`.
|
|
151
|
+
3. Pre-release dry-run:
|
|
152
|
+
```bash
|
|
153
|
+
git tag v0.X.Y-rc.1
|
|
154
|
+
git push --tags
|
|
155
|
+
```
|
|
156
|
+
The `publish.yml` workflow detects the `-rc.N` suffix and routes to TestPyPI. Validate:
|
|
157
|
+
```bash
|
|
158
|
+
pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ onkernel==0.X.Y-rc.1
|
|
159
|
+
python -c "from kernel import Kernel, KernelOutcome; print(KernelOutcome.ALLOWED)"
|
|
160
|
+
```
|
|
161
|
+
4. Real release:
|
|
162
|
+
```bash
|
|
163
|
+
git tag v0.X.Y
|
|
164
|
+
git push --tags
|
|
165
|
+
```
|
|
166
|
+
The same workflow routes the un-suffixed tag to real PyPI.
|
|
167
|
+
|
|
168
|
+
The workflow verifies that the tag matches `pyproject.toml [project].version`
|
|
169
|
+
before building — a mismatched tag fails fast instead of publishing the
|
|
170
|
+
wrong artifact.
|
|
171
|
+
|
|
172
|
+
### Fallback: manual API token
|
|
173
|
+
|
|
174
|
+
If Trusted Publishing isn't yet bound, set repo secret `PYPI_API_TOKEN`
|
|
175
|
+
and replace the OIDC publish step with:
|
|
176
|
+
|
|
177
|
+
```yaml
|
|
178
|
+
- run: twine upload -u __token__ -p "$PYPI_API_TOKEN" dist/*
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Tokens carry blast-radius risk (leak = arbitrary release). Migrate to OIDC
|
|
182
|
+
as soon as the binding is approved on the PyPI side.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
kernel/__init__.py,sha256=TlvsQNgraNb362EU6PuClyWfTsidRIJzuV0j47iZ8-I,2315
|
|
2
|
+
kernel/client.py,sha256=AblkCoZs2U4-ohRiGiEnjW_n3w6u-I_TAHLAvTVAvEg,10733
|
|
3
|
+
kernel/errors.py,sha256=mdv5uD3OUfJ_FGfjDTH6a9vYx5nRlLAk2Rs02_BDmDY,12883
|
|
4
|
+
kernel/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
kernel/types.py,sha256=Z6sKUxF42FCdP7qgSYf9fzm3KNYaVyf9U9rFbN-jBgY,8783
|
|
6
|
+
kernel/crypto/__init__.py,sha256=OSls_1Ms9423eVr6rzmi_K2_CKvPPgsU-2A0lmOgBUA,344
|
|
7
|
+
kernel/crypto/decrypt.py,sha256=ZBuV19MCicVbLT9iuE9W3BF4_tw0PHp-1tifmhD6oTw,1172
|
|
8
|
+
kernel/crypto/keys.py,sha256=NBnmS8v4Tm9_0rRuGHC37kBVe1mw6gQB0-ZSKUk4pYA,1048
|
|
9
|
+
onkernel-0.2.0.dist-info/METADATA,sha256=W4ZVKD4AsfKBy5axqch2Zy7oSfJzF5qrxyv61iRXKmQ,6060
|
|
10
|
+
onkernel-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
11
|
+
onkernel-0.2.0.dist-info/licenses/LICENSE,sha256=UwKLRHQWJbLJ85_qnbzpNvhsuDpAG_rE6DnhVg9ejeI,1077
|
|
12
|
+
onkernel-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kernel (onkernel.ai)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|