korasafe-sdk 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.
- korasafe/__init__.py +37 -0
- korasafe/client.py +192 -0
- korasafe/decorators.py +71 -0
- korasafe/langchain.py +39 -0
- korasafe/llamaindex.py +46 -0
- korasafe/models.py +107 -0
- korasafe/py.typed +1 -0
- korasafe/trace.py +455 -0
- korasafe_sdk-0.2.0.dist-info/METADATA +131 -0
- korasafe_sdk-0.2.0.dist-info/RECORD +11 -0
- korasafe_sdk-0.2.0.dist-info/WHEEL +4 -0
korasafe/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from .client import KoraSafeAPIError, KoraSafeClient
|
|
2
|
+
from .decorators import KoraSafeBlockedError, withKoraSafeScan
|
|
3
|
+
from .langchain import KoraSafeCallback
|
|
4
|
+
from .llamaindex import KoraSafeLlamaIndexMiddleware
|
|
5
|
+
from .models import (
|
|
6
|
+
FindingSubmission,
|
|
7
|
+
GateDecision,
|
|
8
|
+
GateResult,
|
|
9
|
+
GuardianFinding,
|
|
10
|
+
ScanContext,
|
|
11
|
+
ScanInput,
|
|
12
|
+
ScanResult,
|
|
13
|
+
SubmittedFinding,
|
|
14
|
+
)
|
|
15
|
+
from .trace import KoraTrace, init_trace, kora_trace
|
|
16
|
+
|
|
17
|
+
__version__ = "0.2.0"
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"FindingSubmission",
|
|
21
|
+
"GateDecision",
|
|
22
|
+
"GateResult",
|
|
23
|
+
"GuardianFinding",
|
|
24
|
+
"KoraSafeAPIError",
|
|
25
|
+
"KoraSafeBlockedError",
|
|
26
|
+
"KoraSafeCallback",
|
|
27
|
+
"KoraSafeClient",
|
|
28
|
+
"KoraSafeLlamaIndexMiddleware",
|
|
29
|
+
"KoraTrace",
|
|
30
|
+
"ScanContext",
|
|
31
|
+
"ScanInput",
|
|
32
|
+
"ScanResult",
|
|
33
|
+
"SubmittedFinding",
|
|
34
|
+
"init_trace",
|
|
35
|
+
"kora_trace",
|
|
36
|
+
"withKoraSafeScan",
|
|
37
|
+
]
|
korasafe/client.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any, cast
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from .models import (
|
|
9
|
+
FindingSubmission,
|
|
10
|
+
GateDecision,
|
|
11
|
+
GateResult,
|
|
12
|
+
ScanContext,
|
|
13
|
+
ScanInput,
|
|
14
|
+
ScanResult,
|
|
15
|
+
SubmittedFinding,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class KoraSafeAPIError(RuntimeError):
|
|
20
|
+
def __init__(self, message: str, *, status_code: int = 0, code: str = "UNKNOWN") -> None:
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.status_code = status_code
|
|
23
|
+
self.code = code
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class KoraSafeClient:
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
api_key: str | None = None,
|
|
30
|
+
*,
|
|
31
|
+
base_url: str | None = None,
|
|
32
|
+
timeout: float = 30.0,
|
|
33
|
+
http_client: httpx.Client | None = None,
|
|
34
|
+
async_http_client: httpx.AsyncClient | None = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
resolved_key = api_key or os.getenv("KORASAFE_API_KEY")
|
|
37
|
+
if not resolved_key:
|
|
38
|
+
raise ValueError("KoraSafe API key required; pass api_key or set KORASAFE_API_KEY")
|
|
39
|
+
self.api_key = resolved_key
|
|
40
|
+
self.base_url = (
|
|
41
|
+
base_url or os.getenv("KORASAFE_BASE_URL") or "https://korasafe.app"
|
|
42
|
+
).rstrip("/")
|
|
43
|
+
self.timeout = timeout
|
|
44
|
+
self._client = http_client or httpx.Client(timeout=timeout)
|
|
45
|
+
self._async_client = async_http_client or httpx.AsyncClient(timeout=timeout)
|
|
46
|
+
|
|
47
|
+
def scan(
|
|
48
|
+
self,
|
|
49
|
+
input: str | bytes | dict[str, Any] | ScanInput,
|
|
50
|
+
context: dict[str, Any] | ScanContext | None = None,
|
|
51
|
+
) -> ScanResult:
|
|
52
|
+
payload = self._scan_payload(input, context)
|
|
53
|
+
return ScanResult.model_validate(self._request("POST", "/api/guardian-scan", json=payload))
|
|
54
|
+
|
|
55
|
+
async def async_scan(
|
|
56
|
+
self,
|
|
57
|
+
input: str | bytes | dict[str, Any] | ScanInput,
|
|
58
|
+
context: dict[str, Any] | ScanContext | None = None,
|
|
59
|
+
) -> ScanResult:
|
|
60
|
+
payload = self._scan_payload(input, context)
|
|
61
|
+
return ScanResult.model_validate(
|
|
62
|
+
await self._async_request("POST", "/api/guardian-scan", json=payload)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def gate(
|
|
66
|
+
self,
|
|
67
|
+
decision: dict[str, Any] | GateDecision,
|
|
68
|
+
action: str | None = None,
|
|
69
|
+
) -> GateResult:
|
|
70
|
+
payload = self._gate_payload(decision, action)
|
|
71
|
+
return GateResult.model_validate(self._request("POST", "/api/guardian-gate", json=payload))
|
|
72
|
+
|
|
73
|
+
async def async_gate(
|
|
74
|
+
self,
|
|
75
|
+
decision: dict[str, Any] | GateDecision,
|
|
76
|
+
action: str | None = None,
|
|
77
|
+
) -> GateResult:
|
|
78
|
+
payload = self._gate_payload(decision, action)
|
|
79
|
+
return GateResult.model_validate(
|
|
80
|
+
await self._async_request("POST", "/api/guardian-gate", json=payload)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def submit_finding(
|
|
84
|
+
self,
|
|
85
|
+
finding: dict[str, Any] | FindingSubmission,
|
|
86
|
+
**kwargs: Any,
|
|
87
|
+
) -> SubmittedFinding:
|
|
88
|
+
payload = self._finding_payload(finding, kwargs)
|
|
89
|
+
return SubmittedFinding.model_validate(self._request("POST", "/api/findings", json=payload))
|
|
90
|
+
|
|
91
|
+
async def async_submit_finding(
|
|
92
|
+
self,
|
|
93
|
+
finding: dict[str, Any] | FindingSubmission,
|
|
94
|
+
**kwargs: Any,
|
|
95
|
+
) -> SubmittedFinding:
|
|
96
|
+
payload = self._finding_payload(finding, kwargs)
|
|
97
|
+
return SubmittedFinding.model_validate(
|
|
98
|
+
await self._async_request("POST", "/api/findings", json=payload)
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def close(self) -> None:
|
|
102
|
+
self._client.close()
|
|
103
|
+
|
|
104
|
+
async def aclose(self) -> None:
|
|
105
|
+
await self._async_client.aclose()
|
|
106
|
+
|
|
107
|
+
def _scan_payload(
|
|
108
|
+
self,
|
|
109
|
+
input: str | bytes | dict[str, Any] | ScanInput,
|
|
110
|
+
context: dict[str, Any] | ScanContext | None,
|
|
111
|
+
) -> dict[str, Any]:
|
|
112
|
+
scan_input = ScanInput.from_value(input)
|
|
113
|
+
scan_context = context if isinstance(context, ScanContext) else ScanContext.model_validate(
|
|
114
|
+
context or {}
|
|
115
|
+
)
|
|
116
|
+
return {
|
|
117
|
+
"input": scan_input.model_dump(mode="json"),
|
|
118
|
+
"context": scan_context.model_dump(mode="json"),
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
def _gate_payload(
|
|
122
|
+
self,
|
|
123
|
+
decision: dict[str, Any] | GateDecision,
|
|
124
|
+
action: str | None,
|
|
125
|
+
) -> dict[str, Any]:
|
|
126
|
+
if isinstance(decision, GateDecision):
|
|
127
|
+
gate_decision = decision
|
|
128
|
+
else:
|
|
129
|
+
payload = dict(decision)
|
|
130
|
+
if action is not None:
|
|
131
|
+
payload.setdefault("action", action)
|
|
132
|
+
gate_decision = GateDecision.model_validate(payload)
|
|
133
|
+
return gate_decision.model_dump(mode="json")
|
|
134
|
+
|
|
135
|
+
def _finding_payload(
|
|
136
|
+
self,
|
|
137
|
+
finding: dict[str, Any] | FindingSubmission,
|
|
138
|
+
kwargs: dict[str, Any],
|
|
139
|
+
) -> dict[str, Any]:
|
|
140
|
+
payload = (
|
|
141
|
+
finding
|
|
142
|
+
if isinstance(finding, FindingSubmission)
|
|
143
|
+
else FindingSubmission.model_validate({**finding, **kwargs})
|
|
144
|
+
)
|
|
145
|
+
return payload.model_dump(mode="json")
|
|
146
|
+
|
|
147
|
+
def _headers(self) -> dict[str, str]:
|
|
148
|
+
return {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
|
|
149
|
+
|
|
150
|
+
def _request(self, method: str, path: str, *, json: dict[str, Any]) -> dict[str, Any]:
|
|
151
|
+
response = self._client.request(
|
|
152
|
+
method,
|
|
153
|
+
f"{self.base_url}{path}",
|
|
154
|
+
headers=self._headers(),
|
|
155
|
+
json=json,
|
|
156
|
+
)
|
|
157
|
+
return self._decode(response)
|
|
158
|
+
|
|
159
|
+
async def _async_request(
|
|
160
|
+
self,
|
|
161
|
+
method: str,
|
|
162
|
+
path: str,
|
|
163
|
+
*,
|
|
164
|
+
json: dict[str, Any],
|
|
165
|
+
) -> dict[str, Any]:
|
|
166
|
+
response = await self._async_client.request(
|
|
167
|
+
method,
|
|
168
|
+
f"{self.base_url}{path}",
|
|
169
|
+
headers=self._headers(),
|
|
170
|
+
json=json,
|
|
171
|
+
)
|
|
172
|
+
return self._decode(response)
|
|
173
|
+
|
|
174
|
+
def _decode(self, response: httpx.Response) -> dict[str, Any]:
|
|
175
|
+
try:
|
|
176
|
+
data = response.json()
|
|
177
|
+
except ValueError as exc:
|
|
178
|
+
raise KoraSafeAPIError(
|
|
179
|
+
f"Non-JSON response from KoraSafe: HTTP {response.status_code}",
|
|
180
|
+
status_code=response.status_code,
|
|
181
|
+
) from exc
|
|
182
|
+
if response.is_error:
|
|
183
|
+
error = data.get("error", {}) if isinstance(data, dict) else {}
|
|
184
|
+
message = error.get("message") or f"HTTP {response.status_code}"
|
|
185
|
+
code = error.get("code") or "UNKNOWN"
|
|
186
|
+
raise KoraSafeAPIError(message, status_code=response.status_code, code=code)
|
|
187
|
+
if not isinstance(data, dict):
|
|
188
|
+
raise KoraSafeAPIError(
|
|
189
|
+
"KoraSafe response must be a JSON object",
|
|
190
|
+
status_code=response.status_code,
|
|
191
|
+
)
|
|
192
|
+
return cast(dict[str, Any], data)
|
korasafe/decorators.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import inspect
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any, ParamSpec, TypeVar, cast, overload
|
|
7
|
+
|
|
8
|
+
from .client import KoraSafeClient
|
|
9
|
+
from .models import ScanContext
|
|
10
|
+
|
|
11
|
+
P = ParamSpec("P")
|
|
12
|
+
R = TypeVar("R")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class KoraSafeBlockedError(RuntimeError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@overload
|
|
20
|
+
def withKoraSafeScan(handler: Callable[P, R]) -> Callable[P, R]: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@overload
|
|
24
|
+
def withKoraSafeScan(
|
|
25
|
+
handler: None = None,
|
|
26
|
+
*,
|
|
27
|
+
client: KoraSafeClient | None = None,
|
|
28
|
+
context: ScanContext | dict[str, Any] | None = None,
|
|
29
|
+
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def withKoraSafeScan(
|
|
33
|
+
handler: Callable[P, R] | None = None,
|
|
34
|
+
*,
|
|
35
|
+
client: KoraSafeClient | None = None,
|
|
36
|
+
context: ScanContext | dict[str, Any] | None = None,
|
|
37
|
+
) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]:
|
|
38
|
+
def decorate(func: Callable[P, R]) -> Callable[P, R]:
|
|
39
|
+
scanner = client or KoraSafeClient()
|
|
40
|
+
if inspect.iscoroutinefunction(func):
|
|
41
|
+
|
|
42
|
+
@functools.wraps(func)
|
|
43
|
+
async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> Any:
|
|
44
|
+
result = await scanner.async_scan(_input_summary(args, kwargs), context)
|
|
45
|
+
if not result.allowed:
|
|
46
|
+
raise KoraSafeBlockedError(result.action or "KoraSafe blocked this call")
|
|
47
|
+
return await func(*args, **kwargs)
|
|
48
|
+
|
|
49
|
+
return cast(Callable[P, R], async_wrapper)
|
|
50
|
+
|
|
51
|
+
@functools.wraps(func)
|
|
52
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
53
|
+
result = scanner.scan(_input_summary(args, kwargs), context)
|
|
54
|
+
if not result.allowed:
|
|
55
|
+
raise KoraSafeBlockedError(result.action or "KoraSafe blocked this call")
|
|
56
|
+
return func(*args, **kwargs)
|
|
57
|
+
|
|
58
|
+
return wrapper
|
|
59
|
+
|
|
60
|
+
if handler is not None:
|
|
61
|
+
return decorate(handler)
|
|
62
|
+
return decorate
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _input_summary(args: tuple[Any, ...], kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
66
|
+
text = repr({"args": args, "kwargs": kwargs})
|
|
67
|
+
return {
|
|
68
|
+
"content": text,
|
|
69
|
+
"surface": "python-sdk",
|
|
70
|
+
"metadata": {"arg_count": len(args), "kwarg_keys": sorted(kwargs)},
|
|
71
|
+
}
|
korasafe/langchain.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .client import KoraSafeClient
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
from langchain_core.callbacks import BaseCallbackHandler # type: ignore[import-not-found]
|
|
9
|
+
except Exception: # pragma: no cover - optional dependency
|
|
10
|
+
BaseCallbackHandler = object
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class KoraSafeCallback(BaseCallbackHandler): # type: ignore[misc]
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
client: KoraSafeClient | None = None,
|
|
17
|
+
context: dict[str, Any] | None = None,
|
|
18
|
+
) -> None:
|
|
19
|
+
super().__init__()
|
|
20
|
+
self.client = client or KoraSafeClient()
|
|
21
|
+
self.context = context or {}
|
|
22
|
+
|
|
23
|
+
def on_llm_start(self, serialized: dict[str, Any], prompts: list[str], **kwargs: Any) -> None:
|
|
24
|
+
for prompt in prompts:
|
|
25
|
+
self.client.scan(
|
|
26
|
+
{
|
|
27
|
+
"content": prompt,
|
|
28
|
+
"direction": "prompt",
|
|
29
|
+
"surface": "langchain",
|
|
30
|
+
"metadata": {"serialized": serialized},
|
|
31
|
+
},
|
|
32
|
+
{**self.context, "metadata": {"run_id": str(kwargs.get("run_id", ""))}},
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def on_llm_end(self, response: Any, **kwargs: Any) -> None:
|
|
36
|
+
self.client.scan(
|
|
37
|
+
{"content": repr(response), "direction": "response", "surface": "langchain"},
|
|
38
|
+
{**self.context, "metadata": {"run_id": str(kwargs.get("run_id", ""))}},
|
|
39
|
+
)
|
korasafe/llamaindex.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any, TypeVar
|
|
5
|
+
|
|
6
|
+
from .client import KoraSafeClient
|
|
7
|
+
|
|
8
|
+
R = TypeVar("R")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class KoraSafeLlamaIndexMiddleware:
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
client: KoraSafeClient | None = None,
|
|
15
|
+
context: dict[str, Any] | None = None,
|
|
16
|
+
) -> None:
|
|
17
|
+
self.client = client or KoraSafeClient()
|
|
18
|
+
self.context = context or {}
|
|
19
|
+
|
|
20
|
+
def wrap_query_engine(self, query_engine: Any) -> Any:
|
|
21
|
+
original = query_engine.query
|
|
22
|
+
|
|
23
|
+
def query(prompt: str, *args: Any, **kwargs: Any) -> Any:
|
|
24
|
+
self.client.scan(
|
|
25
|
+
{"content": prompt, "surface": "llamaindex", "direction": "prompt"},
|
|
26
|
+
self.context,
|
|
27
|
+
)
|
|
28
|
+
result = original(prompt, *args, **kwargs)
|
|
29
|
+
self.client.scan(
|
|
30
|
+
{"content": repr(result), "surface": "llamaindex", "direction": "response"},
|
|
31
|
+
self.context,
|
|
32
|
+
)
|
|
33
|
+
return result
|
|
34
|
+
|
|
35
|
+
query_engine.query = query
|
|
36
|
+
return query_engine
|
|
37
|
+
|
|
38
|
+
def wrap_callable(self, handler: Callable[..., R]) -> Callable[..., R]:
|
|
39
|
+
def wrapped(*args: Any, **kwargs: Any) -> R:
|
|
40
|
+
self.client.scan(
|
|
41
|
+
{"content": repr({"args": args, "kwargs": kwargs}), "surface": "llamaindex"},
|
|
42
|
+
self.context,
|
|
43
|
+
)
|
|
44
|
+
return handler(*args, **kwargs)
|
|
45
|
+
|
|
46
|
+
return wrapped
|
korasafe/models.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from hashlib import sha256
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
7
|
+
|
|
8
|
+
Metadata = dict[str, Any]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class KoraSafeModel(BaseModel):
|
|
12
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ScanInput(KoraSafeModel):
|
|
16
|
+
content_hash: str = Field(min_length=16)
|
|
17
|
+
content_length: int = Field(ge=0)
|
|
18
|
+
content_type: str = "text/plain"
|
|
19
|
+
direction: Literal["prompt", "response", "tool_input", "tool_output"] = "prompt"
|
|
20
|
+
surface: str = "agent"
|
|
21
|
+
content_ref: str | None = None
|
|
22
|
+
labels: list[str] = Field(default_factory=list)
|
|
23
|
+
metadata: Metadata = Field(default_factory=dict)
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_value(cls, value: str | bytes | dict[str, Any] | ScanInput) -> ScanInput:
|
|
27
|
+
if isinstance(value, cls):
|
|
28
|
+
return value
|
|
29
|
+
if isinstance(value, bytes):
|
|
30
|
+
return cls(content_hash=sha256(value).hexdigest(), content_length=len(value))
|
|
31
|
+
if isinstance(value, str):
|
|
32
|
+
encoded = value.encode("utf-8")
|
|
33
|
+
return cls(content_hash=sha256(encoded).hexdigest(), content_length=len(encoded))
|
|
34
|
+
payload = dict(value)
|
|
35
|
+
raw = payload.pop("content", None)
|
|
36
|
+
if raw is not None and "content_hash" not in payload:
|
|
37
|
+
encoded = raw.encode("utf-8") if isinstance(raw, str) else bytes(raw)
|
|
38
|
+
payload["content_hash"] = sha256(encoded).hexdigest()
|
|
39
|
+
payload["content_length"] = len(encoded)
|
|
40
|
+
return cls.model_validate(payload)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ScanContext(KoraSafeModel):
|
|
44
|
+
system_id: str | None = None
|
|
45
|
+
trace_id: str | None = None
|
|
46
|
+
session_id: str | None = None
|
|
47
|
+
user_id: str | None = None
|
|
48
|
+
org_id: str | None = None
|
|
49
|
+
metadata: Metadata = Field(default_factory=dict)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class GuardianFinding(KoraSafeModel):
|
|
53
|
+
guardian_id: str
|
|
54
|
+
severity: Literal["critical", "high", "medium", "low", "info"] = "info"
|
|
55
|
+
title: str
|
|
56
|
+
confidence: float = Field(ge=0, le=1, default=1)
|
|
57
|
+
evidence_ref: str | None = None
|
|
58
|
+
rule_id: str | None = None
|
|
59
|
+
metadata: Metadata = Field(default_factory=dict)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ScanResult(KoraSafeModel):
|
|
63
|
+
verdict: Literal["pass", "flag", "block"] = "pass"
|
|
64
|
+
allowed: bool = True
|
|
65
|
+
findings: list[GuardianFinding] = Field(default_factory=list)
|
|
66
|
+
action: str | None = None
|
|
67
|
+
request_id: str | None = None
|
|
68
|
+
metadata: Metadata = Field(default_factory=dict)
|
|
69
|
+
|
|
70
|
+
@field_validator("allowed")
|
|
71
|
+
@classmethod
|
|
72
|
+
def block_verdict_disallows(cls, allowed: bool, info: Any) -> bool:
|
|
73
|
+
verdict = info.data.get("verdict")
|
|
74
|
+
if verdict == "block" and allowed:
|
|
75
|
+
return False
|
|
76
|
+
return allowed
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class GateDecision(KoraSafeModel):
|
|
80
|
+
decision_id: str | None = None
|
|
81
|
+
action: str
|
|
82
|
+
risk_tier: Literal["low", "medium", "high", "critical"] = "low"
|
|
83
|
+
metadata: Metadata = Field(default_factory=dict)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class GateResult(KoraSafeModel):
|
|
87
|
+
allowed: bool
|
|
88
|
+
action: Literal["allow", "flag", "block", "require_approval"] = "allow"
|
|
89
|
+
reason: str | None = None
|
|
90
|
+
request_id: str | None = None
|
|
91
|
+
metadata: Metadata = Field(default_factory=dict)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class FindingSubmission(KoraSafeModel):
|
|
95
|
+
guardian_id: str
|
|
96
|
+
title: str
|
|
97
|
+
severity: Literal["critical", "high", "medium", "low", "info"] = "info"
|
|
98
|
+
evidence_ref: str | None = None
|
|
99
|
+
context: ScanContext = Field(default_factory=ScanContext)
|
|
100
|
+
metadata: Metadata = Field(default_factory=dict)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class SubmittedFinding(KoraSafeModel):
|
|
104
|
+
id: str
|
|
105
|
+
status: str = "open"
|
|
106
|
+
request_id: str | None = None
|
|
107
|
+
metadata: Metadata = Field(default_factory=dict)
|
korasafe/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
korasafe/trace.py
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import inspect
|
|
5
|
+
import os
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from contextvars import ContextVar, Token
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
DEFAULT_ENDPOINT = "https://app.korasafe.ai/api/ingest"
|
|
19
|
+
RETRY_BASE_S = 0.25
|
|
20
|
+
RETRY_CAP_S = 5.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class _TraceContext:
|
|
25
|
+
trace_id: str
|
|
26
|
+
task_name: str | None
|
|
27
|
+
started_at_monotonic: float
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_current_trace: ContextVar[_TraceContext | None] = ContextVar(
|
|
31
|
+
"korasafe_current_trace", default=None
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
Logger = Callable[[str, str], None]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _now_iso() -> str:
|
|
38
|
+
return datetime.now(UTC).isoformat()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _new_id() -> str:
|
|
42
|
+
return str(uuid.uuid4())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _silent_logger(_level: str, _message: str) -> None:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class KoraTrace:
|
|
50
|
+
"""Capture agent chain-of-thought (plan, LLM calls, tool calls, reasoning, human approvals)
|
|
51
|
+
and ship them to the KoraSafe audit log.
|
|
52
|
+
|
|
53
|
+
Configure via env vars (KORASAFE_API_KEY, KORASAFE_INGEST_URL) or by passing args.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
*,
|
|
59
|
+
api_key: str | None = None,
|
|
60
|
+
endpoint: str | None = None,
|
|
61
|
+
batch_size: int = 10,
|
|
62
|
+
flush_interval_s: float = 5.0,
|
|
63
|
+
timeout_s: float = 10.0,
|
|
64
|
+
max_retries: int = 3,
|
|
65
|
+
disabled: bool = False,
|
|
66
|
+
logger: Logger | None = None,
|
|
67
|
+
http_client: httpx.Client | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
resolved_key = api_key if api_key is not None else os.getenv("KORASAFE_API_KEY", "")
|
|
70
|
+
self.api_key = resolved_key or ""
|
|
71
|
+
self.endpoint = (
|
|
72
|
+
endpoint or os.getenv("KORASAFE_INGEST_URL") or DEFAULT_ENDPOINT
|
|
73
|
+
)
|
|
74
|
+
self.batch_size = max(1, batch_size)
|
|
75
|
+
self.flush_interval_s = max(0.1, flush_interval_s)
|
|
76
|
+
self.timeout_s = timeout_s
|
|
77
|
+
self.max_retries = max(0, max_retries)
|
|
78
|
+
self.logger: Logger = logger or _silent_logger
|
|
79
|
+
self._client = http_client or httpx.Client(timeout=timeout_s)
|
|
80
|
+
self._owned_client = http_client is None
|
|
81
|
+
self._buffer: list[dict[str, Any]] = []
|
|
82
|
+
self._lock = threading.Lock()
|
|
83
|
+
self._timer: threading.Timer | None = None
|
|
84
|
+
self._closed = False
|
|
85
|
+
|
|
86
|
+
if disabled:
|
|
87
|
+
self.disabled = True
|
|
88
|
+
elif not self.api_key:
|
|
89
|
+
self.disabled = True
|
|
90
|
+
self.logger(
|
|
91
|
+
"warn",
|
|
92
|
+
"korasafe-trace: no API key configured; events will be dropped. "
|
|
93
|
+
"Set KORASAFE_API_KEY or pass api_key.",
|
|
94
|
+
)
|
|
95
|
+
else:
|
|
96
|
+
self.disabled = False
|
|
97
|
+
|
|
98
|
+
@contextmanager
|
|
99
|
+
def run(self, task_name: str) -> Iterator[_TraceContext]:
|
|
100
|
+
"""Open a trace context. Use as a `with` block. Emits run_start + run_end events
|
|
101
|
+
with a shared trace_id. Nested plan/llm_call/tool_call/human_approval calls
|
|
102
|
+
auto-attach to this trace via contextvars.
|
|
103
|
+
"""
|
|
104
|
+
context = _TraceContext(
|
|
105
|
+
trace_id=_new_id(),
|
|
106
|
+
task_name=task_name,
|
|
107
|
+
started_at_monotonic=time.monotonic(),
|
|
108
|
+
)
|
|
109
|
+
token: Token[_TraceContext | None] = _current_trace.set(context)
|
|
110
|
+
self._emit(
|
|
111
|
+
{
|
|
112
|
+
"event_id": _new_id(),
|
|
113
|
+
"event_type": "run_start",
|
|
114
|
+
"trace_id": context.trace_id,
|
|
115
|
+
"span_id": _new_id(),
|
|
116
|
+
"timestamp": _now_iso(),
|
|
117
|
+
"name": task_name,
|
|
118
|
+
"status": "ok",
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
try:
|
|
122
|
+
yield context
|
|
123
|
+
except BaseException as exc:
|
|
124
|
+
self._emit(
|
|
125
|
+
{
|
|
126
|
+
"event_id": _new_id(),
|
|
127
|
+
"event_type": "run_end",
|
|
128
|
+
"trace_id": context.trace_id,
|
|
129
|
+
"span_id": _new_id(),
|
|
130
|
+
"timestamp": _now_iso(),
|
|
131
|
+
"name": task_name,
|
|
132
|
+
"status": "error",
|
|
133
|
+
"duration_ms": int(
|
|
134
|
+
(time.monotonic() - context.started_at_monotonic) * 1000
|
|
135
|
+
),
|
|
136
|
+
"error": str(exc),
|
|
137
|
+
}
|
|
138
|
+
)
|
|
139
|
+
raise
|
|
140
|
+
else:
|
|
141
|
+
self._emit(
|
|
142
|
+
{
|
|
143
|
+
"event_id": _new_id(),
|
|
144
|
+
"event_type": "run_end",
|
|
145
|
+
"trace_id": context.trace_id,
|
|
146
|
+
"span_id": _new_id(),
|
|
147
|
+
"timestamp": _now_iso(),
|
|
148
|
+
"name": task_name,
|
|
149
|
+
"status": "ok",
|
|
150
|
+
"duration_ms": int(
|
|
151
|
+
(time.monotonic() - context.started_at_monotonic) * 1000
|
|
152
|
+
),
|
|
153
|
+
}
|
|
154
|
+
)
|
|
155
|
+
finally:
|
|
156
|
+
_current_trace.reset(token)
|
|
157
|
+
|
|
158
|
+
def trace(self, task_name: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
159
|
+
"""Decorator form. Wraps a function so each call runs inside a fresh trace context."""
|
|
160
|
+
|
|
161
|
+
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
162
|
+
if inspect.iscoroutinefunction(fn):
|
|
163
|
+
|
|
164
|
+
@functools.wraps(fn)
|
|
165
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
166
|
+
with self.run(task_name):
|
|
167
|
+
return await fn(*args, **kwargs)
|
|
168
|
+
|
|
169
|
+
return async_wrapper
|
|
170
|
+
|
|
171
|
+
@functools.wraps(fn)
|
|
172
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
173
|
+
with self.run(task_name):
|
|
174
|
+
return fn(*args, **kwargs)
|
|
175
|
+
|
|
176
|
+
return wrapper
|
|
177
|
+
|
|
178
|
+
return decorator
|
|
179
|
+
|
|
180
|
+
def plan(
|
|
181
|
+
self,
|
|
182
|
+
steps: Sequence[str | dict[str, Any]],
|
|
183
|
+
*,
|
|
184
|
+
reasoning: str | None = None,
|
|
185
|
+
) -> None:
|
|
186
|
+
ctx = self._require_trace()
|
|
187
|
+
normalized: list[dict[str, Any]] = []
|
|
188
|
+
for index, step in enumerate(steps):
|
|
189
|
+
if isinstance(step, str):
|
|
190
|
+
normalized.append({"step": index + 1, "description": step})
|
|
191
|
+
else:
|
|
192
|
+
normalized.append(dict(step))
|
|
193
|
+
plan_payload: dict[str, Any] = {"steps": normalized}
|
|
194
|
+
if reasoning:
|
|
195
|
+
plan_payload["reasoning"] = reasoning
|
|
196
|
+
self._emit(
|
|
197
|
+
{
|
|
198
|
+
"event_id": _new_id(),
|
|
199
|
+
"event_type": "plan",
|
|
200
|
+
"trace_id": ctx.trace_id,
|
|
201
|
+
"span_id": _new_id(),
|
|
202
|
+
"timestamp": _now_iso(),
|
|
203
|
+
"name": "plan",
|
|
204
|
+
"plan": plan_payload,
|
|
205
|
+
}
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def llm_call(
|
|
209
|
+
self,
|
|
210
|
+
*,
|
|
211
|
+
provider: str,
|
|
212
|
+
model: str,
|
|
213
|
+
input: Any,
|
|
214
|
+
output: Any,
|
|
215
|
+
input_tokens: int | None = None,
|
|
216
|
+
output_tokens: int | None = None,
|
|
217
|
+
total_tokens: int | None = None,
|
|
218
|
+
cost_usd: float | None = None,
|
|
219
|
+
duration_ms: int | None = None,
|
|
220
|
+
status: str | None = None,
|
|
221
|
+
error: str | None = None,
|
|
222
|
+
) -> None:
|
|
223
|
+
ctx = self._require_trace()
|
|
224
|
+
tt = total_tokens
|
|
225
|
+
if tt is None and (input_tokens is not None or output_tokens is not None):
|
|
226
|
+
tt = (input_tokens or 0) + (output_tokens or 0)
|
|
227
|
+
self._emit(
|
|
228
|
+
{
|
|
229
|
+
"event_id": _new_id(),
|
|
230
|
+
"event_type": "llm_call",
|
|
231
|
+
"trace_id": ctx.trace_id,
|
|
232
|
+
"span_id": _new_id(),
|
|
233
|
+
"timestamp": _now_iso(),
|
|
234
|
+
"name": f"{provider}:{model}",
|
|
235
|
+
"provider": provider,
|
|
236
|
+
"model": model,
|
|
237
|
+
"status": status or ("error" if error else "ok"),
|
|
238
|
+
"duration_ms": duration_ms,
|
|
239
|
+
"input_tokens": input_tokens,
|
|
240
|
+
"output_tokens": output_tokens,
|
|
241
|
+
"total_tokens": tt,
|
|
242
|
+
"cost_usd": cost_usd,
|
|
243
|
+
"metadata": {"llm_input": input, "llm_output": output},
|
|
244
|
+
"error": error,
|
|
245
|
+
}
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def tool_call(
|
|
249
|
+
self,
|
|
250
|
+
*,
|
|
251
|
+
name: str,
|
|
252
|
+
parameters: dict[str, Any] | None = None,
|
|
253
|
+
response: Any | None = None,
|
|
254
|
+
status: str | None = None,
|
|
255
|
+
duration_ms: int | None = None,
|
|
256
|
+
error: str | None = None,
|
|
257
|
+
) -> None:
|
|
258
|
+
ctx = self._require_trace()
|
|
259
|
+
self._emit(
|
|
260
|
+
{
|
|
261
|
+
"event_id": _new_id(),
|
|
262
|
+
"event_type": "tool_call",
|
|
263
|
+
"trace_id": ctx.trace_id,
|
|
264
|
+
"span_id": _new_id(),
|
|
265
|
+
"timestamp": _now_iso(),
|
|
266
|
+
"name": name,
|
|
267
|
+
"status": status or ("error" if error else "ok"),
|
|
268
|
+
"duration_ms": duration_ms,
|
|
269
|
+
"tool_calls": [
|
|
270
|
+
{
|
|
271
|
+
"name": name,
|
|
272
|
+
"parameters": parameters,
|
|
273
|
+
"response": response,
|
|
274
|
+
"status": status,
|
|
275
|
+
"duration_ms": duration_ms,
|
|
276
|
+
"error": error,
|
|
277
|
+
}
|
|
278
|
+
],
|
|
279
|
+
"error": error,
|
|
280
|
+
}
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
def human_approval(
|
|
284
|
+
self,
|
|
285
|
+
*,
|
|
286
|
+
reviewer_id: str,
|
|
287
|
+
decision: str,
|
|
288
|
+
notes: str | None = None,
|
|
289
|
+
approval_chain: str | None = None,
|
|
290
|
+
) -> None:
|
|
291
|
+
ctx = self._require_trace()
|
|
292
|
+
timestamp = _now_iso()
|
|
293
|
+
self._emit(
|
|
294
|
+
{
|
|
295
|
+
"event_id": _new_id(),
|
|
296
|
+
"event_type": "human_approval",
|
|
297
|
+
"trace_id": ctx.trace_id,
|
|
298
|
+
"span_id": _new_id(),
|
|
299
|
+
"timestamp": timestamp,
|
|
300
|
+
"name": "human_approval",
|
|
301
|
+
"status": "ok",
|
|
302
|
+
"approvals": [
|
|
303
|
+
{
|
|
304
|
+
"reviewer_id": reviewer_id,
|
|
305
|
+
"decision": decision,
|
|
306
|
+
"notes": notes,
|
|
307
|
+
"timestamp": timestamp,
|
|
308
|
+
}
|
|
309
|
+
],
|
|
310
|
+
"metadata": {"approval_chain": approval_chain} if approval_chain else None,
|
|
311
|
+
}
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
def flush(self) -> None:
|
|
315
|
+
with self._lock:
|
|
316
|
+
if not self._buffer:
|
|
317
|
+
self._cancel_timer_locked()
|
|
318
|
+
return
|
|
319
|
+
batch = self._buffer
|
|
320
|
+
self._buffer = []
|
|
321
|
+
self._cancel_timer_locked()
|
|
322
|
+
self._post(batch)
|
|
323
|
+
|
|
324
|
+
def close(self) -> None:
|
|
325
|
+
with self._lock:
|
|
326
|
+
self._closed = True
|
|
327
|
+
self.flush()
|
|
328
|
+
if self._owned_client:
|
|
329
|
+
self._client.close()
|
|
330
|
+
|
|
331
|
+
def pending_count(self) -> int:
|
|
332
|
+
with self._lock:
|
|
333
|
+
return len(self._buffer)
|
|
334
|
+
|
|
335
|
+
def _require_trace(self) -> _TraceContext:
|
|
336
|
+
ctx = _current_trace.get()
|
|
337
|
+
if ctx is None:
|
|
338
|
+
raise RuntimeError(
|
|
339
|
+
"korasafe-trace: no active trace context; wrap calls in "
|
|
340
|
+
"`with kora_trace.run(name):` (or use the @kora_trace.trace(name) decorator) "
|
|
341
|
+
"before emitting events"
|
|
342
|
+
)
|
|
343
|
+
return ctx
|
|
344
|
+
|
|
345
|
+
def _emit(self, event: dict[str, Any]) -> None:
|
|
346
|
+
cleaned = {k: v for k, v in event.items() if v is not None}
|
|
347
|
+
if self.disabled:
|
|
348
|
+
self.logger(
|
|
349
|
+
"info",
|
|
350
|
+
f"korasafe-trace: event dropped (disabled): {cleaned.get('event_type')}",
|
|
351
|
+
)
|
|
352
|
+
return
|
|
353
|
+
batch: list[dict[str, Any]] | None = None
|
|
354
|
+
with self._lock:
|
|
355
|
+
if self._closed:
|
|
356
|
+
return
|
|
357
|
+
self._buffer.append(cleaned)
|
|
358
|
+
if len(self._buffer) >= self.batch_size:
|
|
359
|
+
batch = self._buffer
|
|
360
|
+
self._buffer = []
|
|
361
|
+
self._cancel_timer_locked()
|
|
362
|
+
else:
|
|
363
|
+
self._schedule_timer_locked()
|
|
364
|
+
if batch is not None:
|
|
365
|
+
self._post(batch)
|
|
366
|
+
|
|
367
|
+
def _schedule_timer_locked(self) -> None:
|
|
368
|
+
if self._timer is not None:
|
|
369
|
+
return
|
|
370
|
+
timer = threading.Timer(self.flush_interval_s, self._on_timer)
|
|
371
|
+
timer.daemon = True
|
|
372
|
+
self._timer = timer
|
|
373
|
+
timer.start()
|
|
374
|
+
|
|
375
|
+
def _cancel_timer_locked(self) -> None:
|
|
376
|
+
if self._timer is not None:
|
|
377
|
+
self._timer.cancel()
|
|
378
|
+
self._timer = None
|
|
379
|
+
|
|
380
|
+
def _on_timer(self) -> None:
|
|
381
|
+
try:
|
|
382
|
+
self.flush()
|
|
383
|
+
except Exception as exc:
|
|
384
|
+
self.logger("error", f"korasafe-trace: scheduled flush failed: {exc}")
|
|
385
|
+
|
|
386
|
+
def _post(self, events: list[dict[str, Any]]) -> None:
|
|
387
|
+
if not events:
|
|
388
|
+
return
|
|
389
|
+
payload = {"type": "agent_trace_events", "events": events}
|
|
390
|
+
headers = {
|
|
391
|
+
"authorization": f"Bearer {self.api_key}",
|
|
392
|
+
"x-korasafe-sdk": "korasafe-sdk-python",
|
|
393
|
+
}
|
|
394
|
+
last_error: Exception | None = None
|
|
395
|
+
for attempt in range(self.max_retries + 1):
|
|
396
|
+
try:
|
|
397
|
+
response = self._client.post(
|
|
398
|
+
self.endpoint,
|
|
399
|
+
json=payload,
|
|
400
|
+
headers=headers,
|
|
401
|
+
timeout=self.timeout_s,
|
|
402
|
+
)
|
|
403
|
+
except httpx.HTTPError as exc:
|
|
404
|
+
last_error = exc
|
|
405
|
+
if attempt == self.max_retries:
|
|
406
|
+
self.logger(
|
|
407
|
+
"error", f"korasafe-trace: ingest failed after retries: {exc}"
|
|
408
|
+
)
|
|
409
|
+
return
|
|
410
|
+
backoff = min(RETRY_CAP_S, RETRY_BASE_S * (2**attempt))
|
|
411
|
+
self.logger(
|
|
412
|
+
"warn", f"korasafe-trace: ingest retry {attempt + 1}: {exc}"
|
|
413
|
+
)
|
|
414
|
+
time.sleep(backoff)
|
|
415
|
+
continue
|
|
416
|
+
|
|
417
|
+
if response.status_code >= 500:
|
|
418
|
+
last_error = httpx.HTTPStatusError(
|
|
419
|
+
f"HTTP {response.status_code}", request=response.request, response=response
|
|
420
|
+
)
|
|
421
|
+
if attempt == self.max_retries:
|
|
422
|
+
self.logger(
|
|
423
|
+
"error",
|
|
424
|
+
f"korasafe-trace: ingest failed after retries: HTTP {response.status_code}",
|
|
425
|
+
)
|
|
426
|
+
return
|
|
427
|
+
backoff = min(RETRY_CAP_S, RETRY_BASE_S * (2**attempt))
|
|
428
|
+
self.logger(
|
|
429
|
+
"warn",
|
|
430
|
+
f"korasafe-trace: ingest retry {attempt + 1}: HTTP {response.status_code}",
|
|
431
|
+
)
|
|
432
|
+
time.sleep(backoff)
|
|
433
|
+
continue
|
|
434
|
+
if response.status_code >= 400:
|
|
435
|
+
body_preview = response.text[:500] if response.text else ""
|
|
436
|
+
self.logger(
|
|
437
|
+
"error",
|
|
438
|
+
f"korasafe-trace: ingest rejected HTTP {response.status_code}: {body_preview}",
|
|
439
|
+
)
|
|
440
|
+
return
|
|
441
|
+
return
|
|
442
|
+
if last_error is not None:
|
|
443
|
+
self.logger(
|
|
444
|
+
"error", f"korasafe-trace: ingest exhausted retries: {last_error}"
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
kora_trace = KoraTrace()
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def init_trace(**kwargs: Any) -> KoraTrace:
|
|
452
|
+
"""Reinitialize the module-level singleton `kora_trace`."""
|
|
453
|
+
global kora_trace
|
|
454
|
+
kora_trace = KoraTrace(**kwargs)
|
|
455
|
+
return kora_trace
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: korasafe-sdk
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: KoraSafe Python SDK — inline guardian scans + chain-of-thought trace capture for Python agents
|
|
5
|
+
Project-URL: Homepage, https://korasafe.app
|
|
6
|
+
Project-URL: Repository, https://github.com/korasafe/platform
|
|
7
|
+
Project-URL: Documentation, https://github.com/korasafe/platform/tree/main/docs/sdk/python.md
|
|
8
|
+
Author: KoraSafe
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: agents,ai-governance,guardrails,korasafe,langchain,llamaindex
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Requires-Dist: httpx<1,>=0.27
|
|
17
|
+
Requires-Dist: pydantic<3,>=2.7
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
20
|
+
Requires-Dist: coverage[toml]>=7.5; extra == 'dev'
|
|
21
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest>=8.2; extra == 'dev'
|
|
23
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
24
|
+
Provides-Extra: langchain
|
|
25
|
+
Requires-Dist: langchain-core>=0.2; extra == 'langchain'
|
|
26
|
+
Provides-Extra: llamaindex
|
|
27
|
+
Requires-Dist: llama-index-core>=0.10; extra == 'llamaindex'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# KoraSafe Python SDK
|
|
31
|
+
|
|
32
|
+
Two surfaces in one package:
|
|
33
|
+
|
|
34
|
+
- `KoraSafeClient` — inline guardian inspection (scan, gate, submit findings). Metadata-only transport.
|
|
35
|
+
- `kora_trace` — capture agent chain-of-thought (plan, LLM calls, tool calls, reasoning, human approvals) and ship to the KoraSafe audit log.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install korasafe-sdk
|
|
39
|
+
export KORASAFE_API_KEY=ks_live_...
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## kora_trace — chain-of-thought capture
|
|
43
|
+
|
|
44
|
+
10-line FastAPI example:
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from fastapi import FastAPI
|
|
48
|
+
from korasafe import init_trace, kora_trace
|
|
49
|
+
|
|
50
|
+
init_trace()
|
|
51
|
+
app = FastAPI()
|
|
52
|
+
|
|
53
|
+
@app.post("/classify")
|
|
54
|
+
def classify(text: str) -> dict[str, str]:
|
|
55
|
+
with kora_trace.run("classify_claim"):
|
|
56
|
+
kora_trace.plan(["look up policy", "score risk", "route"])
|
|
57
|
+
kora_trace.llm_call(provider="openai", model="gpt-4o", input=text, output="tier=gold", input_tokens=120, output_tokens=30)
|
|
58
|
+
kora_trace.tool_call(name="policy_lookup", parameters={"id": "pol-7"}, response={"tier": "gold"}, status="ok")
|
|
59
|
+
kora_trace.human_approval(reviewer_id="user-42", decision="approved")
|
|
60
|
+
return {"tier": "gold"}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Events appear in the KoraSafe audit log within 5 seconds.
|
|
64
|
+
|
|
65
|
+
### API
|
|
66
|
+
|
|
67
|
+
| Method | Purpose |
|
|
68
|
+
|---|---|
|
|
69
|
+
| `kora_trace.run(task_name)` | Context manager. Opens a trace; `run_start` + `run_end` events bracket the block. Nested method calls auto-attach via contextvars. |
|
|
70
|
+
| `@kora_trace.trace(task_name)` | Decorator equivalent of `run`. Works on sync and async functions. |
|
|
71
|
+
| `kora_trace.plan(steps, reasoning=None)` | Log initial plan. `steps` can be strings or `{step, description}` dicts. |
|
|
72
|
+
| `kora_trace.llm_call(provider, model, input, output, input_tokens=, output_tokens=, total_tokens=, cost_usd=, duration_ms=, status=, error=)` | Log an LLM invocation. Tokens auto-sum if `total_tokens` omitted. |
|
|
73
|
+
| `kora_trace.tool_call(name, parameters=, response=, status=, duration_ms=, error=)` | Log a tool or external API call. |
|
|
74
|
+
| `kora_trace.human_approval(reviewer_id, decision, notes=, approval_chain=)` | Log a HITL decision. |
|
|
75
|
+
| `kora_trace.flush()` | Force-flush buffered events. |
|
|
76
|
+
| `kora_trace.close()` | Drain buffer + close HTTP client. |
|
|
77
|
+
|
|
78
|
+
`init_trace(**kwargs)` reinitializes the singleton with overrides (`api_key`, `endpoint`, `batch_size=10`, `flush_interval_s=5`, `timeout_s=10`, `max_retries=3`, `disabled=False`, `logger=`, `http_client=`).
|
|
79
|
+
|
|
80
|
+
Calling `plan` / `llm_call` / `tool_call` / `human_approval` outside a `run()` context raises `RuntimeError` — wrap your agent loop first.
|
|
81
|
+
|
|
82
|
+
## KoraSafeClient — guardian inspection
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from korasafe import KoraSafeClient, withKoraSafeScan
|
|
86
|
+
|
|
87
|
+
client = KoraSafeClient()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@withKoraSafeScan(client=client, context={"system_id": "claims-agent"})
|
|
91
|
+
def answer_claim(prompt: str) -> str:
|
|
92
|
+
return "approved"
|
|
93
|
+
|
|
94
|
+
result = client.scan("Does this contain PII?", {"system_id": "claims-agent"})
|
|
95
|
+
gate = client.gate({"action": "payment_approval", "risk_tier": "high"})
|
|
96
|
+
finding = client.submit_finding({"guardian_id": "pii", "title": "PII found", "severity": "high"})
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Raw strings passed to `scan()` or the decorator are hashed locally. The SDK sends content hash, byte length, direction, surface, labels, and caller metadata rather than prompt or response bodies.
|
|
100
|
+
|
|
101
|
+
## Frameworks
|
|
102
|
+
|
|
103
|
+
LangChain:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from korasafe import KoraSafeCallback, KoraSafeClient
|
|
107
|
+
|
|
108
|
+
callbacks = [KoraSafeCallback(client=KoraSafeClient(), context={"system_id": "claims-agent"})]
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
LlamaIndex:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from korasafe import KoraSafeClient, KoraSafeLlamaIndexMiddleware
|
|
115
|
+
|
|
116
|
+
query_engine = KoraSafeLlamaIndexMiddleware(KoraSafeClient()).wrap_query_engine(query_engine)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Development
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
cd packages/sdk-python
|
|
123
|
+
python -m pip install -e ".[dev]"
|
|
124
|
+
ruff check .
|
|
125
|
+
mypy .
|
|
126
|
+
coverage run -m pytest
|
|
127
|
+
coverage report
|
|
128
|
+
python -m build
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Publishing uses GitHub OIDC trusted publishing to PyPI from `sdk-python-v*` tags.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
korasafe/__init__.py,sha256=N62zs32NeFAtPutx8WmDotGptqE1FCutgDVgqHB1RHs,847
|
|
2
|
+
korasafe/client.py,sha256=hOd5Kct0paiWNfv0TIcONcHT6SS6Ny_Nk3DeIHLpQy4,6471
|
|
3
|
+
korasafe/decorators.py,sha256=Jg6yqVNlKr0YI1Mhdw8dSzWbVpRjxkZLX6waCadlp-o,2182
|
|
4
|
+
korasafe/langchain.py,sha256=f5Nz1ENAa6Ff8YpL85x9AlVjSvvlGypS483_F8p2GsQ,1374
|
|
5
|
+
korasafe/llamaindex.py,sha256=dVeYsNehtgyp9xrN6EizqUXfPGqvBLvwuW2bo8WYAMI,1425
|
|
6
|
+
korasafe/models.py,sha256=rxlpc9k5Rzl6LhXN2w4g0ZWf8lrim-2sx7AXvs8wbIQ,3586
|
|
7
|
+
korasafe/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
8
|
+
korasafe/trace.py,sha256=fdkr4Q2V9EwI2_oTyMdxEpsyF1yK6GnOcHQ4SaupAss,14949
|
|
9
|
+
korasafe_sdk-0.2.0.dist-info/METADATA,sha256=qOq-TpzfmK8zLGnmNpxUjWzjWtGMjApgumceaTvETE0,5020
|
|
10
|
+
korasafe_sdk-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
11
|
+
korasafe_sdk-0.2.0.dist-info/RECORD,,
|