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 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,,
@@ -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