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 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
+ ]
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.