znyx-runtime 1.0.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.
- znyx_runtime/__init__.py +0 -0
- znyx_runtime/api/__init__.py +0 -0
- znyx_runtime/api/routes.py +284 -0
- znyx_runtime/api/stream_routes.py +101 -0
- znyx_runtime/audit_sink.py +195 -0
- znyx_runtime/bundle_manager.py +267 -0
- znyx_runtime/cli.py +40 -0
- znyx_runtime/config.py +174 -0
- znyx_runtime/heartbeat.py +133 -0
- znyx_runtime/install_state.py +102 -0
- znyx_runtime/judge_audit_sink.py +169 -0
- znyx_runtime/main.py +322 -0
- znyx_runtime/telemetry.py +96 -0
- znyx_runtime-1.0.0.dist-info/METADATA +57 -0
- znyx_runtime-1.0.0.dist-info/RECORD +18 -0
- znyx_runtime-1.0.0.dist-info/WHEEL +5 -0
- znyx_runtime-1.0.0.dist-info/entry_points.txt +2 -0
- znyx_runtime-1.0.0.dist-info/top_level.txt +1 -0
znyx_runtime/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Runtime API routes - evaluation endpoints only.
|
|
3
|
+
No DB dependency, no admin routes.
|
|
4
|
+
"""
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, Depends, HTTPException, Header
|
|
10
|
+
|
|
11
|
+
from znyx_core.core.models import (
|
|
12
|
+
EvaluationRequest, EvaluationResponse, HealthResponse, ToolEvaluationRequest,
|
|
13
|
+
RetrievalEvaluationRequest, AgentPlanEvaluationRequest, AgentStepEvaluationRequest,
|
|
14
|
+
MemoryWriteEvaluationRequest,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
router = APIRouter()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _is_prod() -> bool:
|
|
22
|
+
from znyx_core.utils.env import is_production
|
|
23
|
+
return is_production()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _runtime_auth_required() -> bool:
|
|
27
|
+
"""Default: on. Only explicitly disabled when RUNTIME_REQUIRE_AUTH=false,
|
|
28
|
+
and only honoured outside production — production always requires auth."""
|
|
29
|
+
raw = os.getenv("RUNTIME_REQUIRE_AUTH", "true").strip().lower()
|
|
30
|
+
if _is_prod():
|
|
31
|
+
return True
|
|
32
|
+
return raw not in ("0", "false", "no", "off")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def _optional_runtime_auth(
|
|
36
|
+
x_api_key: Optional[str] = Header(default=None, alias="X-API-Key"),
|
|
37
|
+
authorization: Optional[str] = Header(default=None),
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Enforce runtime auth. Default-on; only disablable in non-production."""
|
|
40
|
+
if not _runtime_auth_required():
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
expected = os.getenv("RUNTIME_API_KEY", "").strip()
|
|
44
|
+
if not expected:
|
|
45
|
+
raise HTTPException(
|
|
46
|
+
status_code=500,
|
|
47
|
+
detail="Server misconfiguration: RUNTIME_REQUIRE_AUTH is enabled but RUNTIME_API_KEY is not set.",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Accept either X-API-Key or Authorization: Bearer
|
|
51
|
+
token = x_api_key
|
|
52
|
+
if not token and authorization and authorization.startswith("Bearer "):
|
|
53
|
+
token = authorization[7:]
|
|
54
|
+
|
|
55
|
+
if not token:
|
|
56
|
+
raise HTTPException(
|
|
57
|
+
status_code=401,
|
|
58
|
+
detail="Authentication required. Set X-API-Key or Authorization: Bearer header.",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Constant-time compare to prevent timing attacks.
|
|
62
|
+
import hmac as _hmac
|
|
63
|
+
|
|
64
|
+
if not _hmac.compare_digest(token, expected):
|
|
65
|
+
raise HTTPException(status_code=401, detail="Invalid authentication token")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _get_evaluator():
|
|
69
|
+
from znyx_runtime.main import evaluator
|
|
70
|
+
return evaluator
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _get_bundle_manager():
|
|
74
|
+
from znyx_runtime.main import bundle_manager
|
|
75
|
+
return bundle_manager
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _get_heartbeat():
|
|
79
|
+
from znyx_runtime.main import heartbeat
|
|
80
|
+
return heartbeat
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _get_runtime_judge():
|
|
84
|
+
from znyx_runtime.main import runtime_judge
|
|
85
|
+
return runtime_judge
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _runtime_judge_ctx(policy, scoped):
|
|
89
|
+
"""Build the per-request judge context for a runtime evaluation, or None
|
|
90
|
+
when judges aren't used / judge audit is off. Stamps the request's tenant as org_scope
|
|
91
|
+
so the CP drain can attribute the spooled rows."""
|
|
92
|
+
rj = _get_runtime_judge()
|
|
93
|
+
if rj is None:
|
|
94
|
+
return None
|
|
95
|
+
return rj.context_for(policy, env=getattr(scoped, "env", None) or "prod",
|
|
96
|
+
org_scope=getattr(scoped, "tenant_id", None))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# Public aliases consumed by stream_routes and any other runtime-scoped code.
|
|
100
|
+
# These intentionally shadow the private underscore names so callers can do:
|
|
101
|
+
# from znyx_runtime.api.routes import verify_runtime_auth, get_evaluator
|
|
102
|
+
verify_runtime_auth = _optional_runtime_auth
|
|
103
|
+
get_evaluator = _get_evaluator
|
|
104
|
+
get_heartbeat = _get_heartbeat
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@router.post("/v1/evaluate/input", response_model=EvaluationResponse, dependencies=[Depends(_optional_runtime_auth)])
|
|
108
|
+
async def evaluate_input(request: EvaluationRequest) -> EvaluationResponse:
|
|
109
|
+
"""Evaluate input text before sending to LLM."""
|
|
110
|
+
try:
|
|
111
|
+
bm = _get_bundle_manager()
|
|
112
|
+
if bm.effective_env:
|
|
113
|
+
request = request.model_copy(update={"env": bm.effective_env})
|
|
114
|
+
policy = bm.get_policy(
|
|
115
|
+
tenant_id=request.tenant_id,
|
|
116
|
+
app_id=request.app_id,
|
|
117
|
+
agent_id=request.agent_id,
|
|
118
|
+
env=request.env,
|
|
119
|
+
)
|
|
120
|
+
ev = _get_evaluator()
|
|
121
|
+
result = await ev.evaluate(request, context="input", policy=policy,
|
|
122
|
+
judge_ctx=_runtime_judge_ctx(policy, request))
|
|
123
|
+
hb = _get_heartbeat()
|
|
124
|
+
if hb:
|
|
125
|
+
hb.increment_eval_count()
|
|
126
|
+
return result
|
|
127
|
+
except RuntimeError as e:
|
|
128
|
+
logger.error(f"Policy unavailable: {e}")
|
|
129
|
+
raise HTTPException(status_code=503, detail="Policy unavailable (fail-closed)")
|
|
130
|
+
except Exception:
|
|
131
|
+
logger.exception("Evaluation failed")
|
|
132
|
+
raise HTTPException(status_code=500, detail="Evaluation failed")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@router.post("/v1/evaluate/output", response_model=EvaluationResponse, dependencies=[Depends(_optional_runtime_auth)])
|
|
136
|
+
async def evaluate_output(request: EvaluationRequest) -> EvaluationResponse:
|
|
137
|
+
"""Evaluate output text from LLM before returning to user."""
|
|
138
|
+
try:
|
|
139
|
+
bm = _get_bundle_manager()
|
|
140
|
+
if bm.effective_env:
|
|
141
|
+
request = request.model_copy(update={"env": bm.effective_env})
|
|
142
|
+
policy = bm.get_policy(
|
|
143
|
+
tenant_id=request.tenant_id,
|
|
144
|
+
app_id=request.app_id,
|
|
145
|
+
agent_id=request.agent_id,
|
|
146
|
+
env=request.env,
|
|
147
|
+
)
|
|
148
|
+
ev = _get_evaluator()
|
|
149
|
+
result = await ev.evaluate(request, context="output", policy=policy,
|
|
150
|
+
judge_ctx=_runtime_judge_ctx(policy, request))
|
|
151
|
+
hb = _get_heartbeat()
|
|
152
|
+
if hb:
|
|
153
|
+
hb.increment_eval_count()
|
|
154
|
+
return result
|
|
155
|
+
except RuntimeError as e:
|
|
156
|
+
logger.error(f"Policy unavailable: {e}")
|
|
157
|
+
raise HTTPException(status_code=503, detail="Policy unavailable (fail-closed)")
|
|
158
|
+
except Exception:
|
|
159
|
+
logger.exception("Evaluation failed")
|
|
160
|
+
raise HTTPException(status_code=500, detail="Evaluation failed")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@router.post("/v1/evaluate/tool", response_model=EvaluationResponse, dependencies=[Depends(_optional_runtime_auth)])
|
|
164
|
+
async def evaluate_tool(request: ToolEvaluationRequest) -> EvaluationResponse:
|
|
165
|
+
"""Evaluate tool invocation against governance policies."""
|
|
166
|
+
try:
|
|
167
|
+
bm = _get_bundle_manager()
|
|
168
|
+
if bm.effective_env:
|
|
169
|
+
request = request.model_copy(update={"env": bm.effective_env})
|
|
170
|
+
policy = bm.get_policy(
|
|
171
|
+
tenant_id=request.tenant_id,
|
|
172
|
+
app_id=request.app_id,
|
|
173
|
+
agent_id=request.agent_id,
|
|
174
|
+
env=request.env,
|
|
175
|
+
)
|
|
176
|
+
ev = _get_evaluator()
|
|
177
|
+
result = await ev.evaluate_tool(request, policy=policy,
|
|
178
|
+
judge_ctx=_runtime_judge_ctx(policy, request))
|
|
179
|
+
hb = _get_heartbeat()
|
|
180
|
+
if hb:
|
|
181
|
+
hb.increment_eval_count()
|
|
182
|
+
return result
|
|
183
|
+
except RuntimeError as e:
|
|
184
|
+
logger.error(f"Policy unavailable: {e}")
|
|
185
|
+
raise HTTPException(status_code=503, detail="Policy unavailable (fail-closed)")
|
|
186
|
+
except Exception:
|
|
187
|
+
logger.exception("Tool evaluation failed")
|
|
188
|
+
raise HTTPException(status_code=500, detail="Tool evaluation failed")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ── New-stage evaluate endpoints (generalized stage dispatch) ──────
|
|
192
|
+
|
|
193
|
+
async def _evaluate_stage_runtime(scoped, stage: str) -> EvaluationResponse:
|
|
194
|
+
"""Shared runtime handler for the per-stage endpoints: resolve the bundle
|
|
195
|
+
policy for the request scope and dispatch through the generalized stage pipeline."""
|
|
196
|
+
bm = _get_bundle_manager()
|
|
197
|
+
if bm.effective_env:
|
|
198
|
+
scoped = scoped.model_copy(update={"env": bm.effective_env})
|
|
199
|
+
policy = bm.get_policy(
|
|
200
|
+
tenant_id=scoped.tenant_id,
|
|
201
|
+
app_id=scoped.app_id,
|
|
202
|
+
agent_id=scoped.agent_id,
|
|
203
|
+
env=scoped.env,
|
|
204
|
+
)
|
|
205
|
+
ev = _get_evaluator()
|
|
206
|
+
result = await ev.evaluate_stage(scoped, stage, policy=policy,
|
|
207
|
+
judge_ctx=_runtime_judge_ctx(policy, scoped))
|
|
208
|
+
hb = _get_heartbeat()
|
|
209
|
+
if hb:
|
|
210
|
+
hb.increment_eval_count()
|
|
211
|
+
return result
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@router.post("/v1/evaluate/retrieval", response_model=EvaluationResponse, dependencies=[Depends(_optional_runtime_auth)])
|
|
215
|
+
async def evaluate_retrieval(request: RetrievalEvaluationRequest) -> EvaluationResponse:
|
|
216
|
+
"""Evaluate retrieved RAG chunks for indirect prompt injection before they enter context (LLM01)."""
|
|
217
|
+
try:
|
|
218
|
+
return await _evaluate_stage_runtime(request, "retrieval")
|
|
219
|
+
except RuntimeError:
|
|
220
|
+
raise HTTPException(status_code=503, detail="Policy unavailable (fail-closed)")
|
|
221
|
+
except Exception:
|
|
222
|
+
logger.exception("Retrieval evaluation failed")
|
|
223
|
+
raise HTTPException(status_code=500, detail="Evaluation failed")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@router.post("/v1/evaluate/agent-plan", response_model=EvaluationResponse, dependencies=[Depends(_optional_runtime_auth)])
|
|
227
|
+
async def evaluate_agent_plan(request: AgentPlanEvaluationRequest) -> EvaluationResponse:
|
|
228
|
+
"""Evaluate a proposed multi-step agent plan for excessive agency (LLM06)."""
|
|
229
|
+
try:
|
|
230
|
+
return await _evaluate_stage_runtime(request, "agent_plan")
|
|
231
|
+
except RuntimeError:
|
|
232
|
+
raise HTTPException(status_code=503, detail="Policy unavailable (fail-closed)")
|
|
233
|
+
except Exception:
|
|
234
|
+
logger.exception("Agent-plan evaluation failed")
|
|
235
|
+
raise HTTPException(status_code=500, detail="Evaluation failed")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@router.post("/v1/evaluate/agent-step", response_model=EvaluationResponse, dependencies=[Depends(_optional_runtime_auth)])
|
|
239
|
+
async def evaluate_agent_step(request: AgentStepEvaluationRequest) -> EvaluationResponse:
|
|
240
|
+
"""Evaluate a single agent-loop iteration for budget/depth caps (LLM10)."""
|
|
241
|
+
try:
|
|
242
|
+
return await _evaluate_stage_runtime(request, "agent_loop")
|
|
243
|
+
except RuntimeError:
|
|
244
|
+
raise HTTPException(status_code=503, detail="Policy unavailable (fail-closed)")
|
|
245
|
+
except Exception:
|
|
246
|
+
logger.exception("Agent-step evaluation failed")
|
|
247
|
+
raise HTTPException(status_code=500, detail="Evaluation failed")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@router.post("/v1/evaluate/memory-write", response_model=EvaluationResponse, dependencies=[Depends(_optional_runtime_auth)])
|
|
251
|
+
async def evaluate_memory_write(request: MemoryWriteEvaluationRequest) -> EvaluationResponse:
|
|
252
|
+
"""Evaluate text being written to agent memory for persistent injection (LLM01)."""
|
|
253
|
+
try:
|
|
254
|
+
return await _evaluate_stage_runtime(request, "memory_write")
|
|
255
|
+
except RuntimeError:
|
|
256
|
+
raise HTTPException(status_code=503, detail="Policy unavailable (fail-closed)")
|
|
257
|
+
except Exception:
|
|
258
|
+
logger.exception("Memory-write evaluation failed")
|
|
259
|
+
raise HTTPException(status_code=500, detail="Evaluation failed")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@router.get("/healthz", response_model=HealthResponse)
|
|
263
|
+
async def health_check() -> HealthResponse:
|
|
264
|
+
"""Health check - always returns 200."""
|
|
265
|
+
return HealthResponse(status="ok")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@router.get("/readyz", response_model=HealthResponse)
|
|
269
|
+
async def readiness_check() -> HealthResponse:
|
|
270
|
+
"""Readiness check - returns 200 only if a policy is loaded."""
|
|
271
|
+
bm = _get_bundle_manager()
|
|
272
|
+
if bm.is_ready:
|
|
273
|
+
return HealthResponse(status="ready")
|
|
274
|
+
raise HTTPException(status_code=503, detail="Not ready - no policy loaded")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@router.get("/v1/bundle/status")
|
|
278
|
+
async def bundle_status():
|
|
279
|
+
"""Return metadata about the currently loaded policy bundle."""
|
|
280
|
+
bm = _get_bundle_manager()
|
|
281
|
+
return {
|
|
282
|
+
"ready": bm.is_ready,
|
|
283
|
+
**bm.bundle_info,
|
|
284
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Streaming guardrails SSE endpoint - evaluate LLM output in real-time."""
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
from fastapi import APIRouter, Depends
|
|
6
|
+
from fastapi.responses import StreamingResponse
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from znyx_runtime.api.routes import verify_runtime_auth, get_evaluator, get_heartbeat
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
router = APIRouter(tags=["Runtime API"])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class StreamEvaluateRequest(BaseModel):
|
|
16
|
+
"""Request body for streaming evaluation."""
|
|
17
|
+
request_id: str = "stream-0"
|
|
18
|
+
tenant_id: str = "default"
|
|
19
|
+
app_id: str = "default"
|
|
20
|
+
context: str = Field(default="output", description="input or output")
|
|
21
|
+
chunks: List[str] = Field(..., description="Text chunks to evaluate in order")
|
|
22
|
+
policy: Optional[Dict[str, Any]] = Field(default=None, description="Inline policy (optional)")
|
|
23
|
+
window_size: int = Field(default=200, ge=50, le=2000)
|
|
24
|
+
overlap: int = Field(default=40, ge=0, le=500)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@router.post(
|
|
28
|
+
"/v1/evaluate/stream",
|
|
29
|
+
operation_id="runtime.evaluateStream",
|
|
30
|
+
summary="Evaluate streaming LLM output as it arrives",
|
|
31
|
+
)
|
|
32
|
+
async def evaluate_stream(
|
|
33
|
+
body: StreamEvaluateRequest,
|
|
34
|
+
_auth=Depends(verify_runtime_auth),
|
|
35
|
+
evaluator=Depends(get_evaluator),
|
|
36
|
+
):
|
|
37
|
+
"""Evaluate text chunks via Server-Sent Events.
|
|
38
|
+
|
|
39
|
+
Accepts a list of text chunks and streams back SSE events as each window
|
|
40
|
+
is evaluated. Events:
|
|
41
|
+
- ``chunk``: forwarded text chunk
|
|
42
|
+
- ``guardrail``: window evaluation result (non-blocking)
|
|
43
|
+
- ``block``: window triggered a BLOCK decision
|
|
44
|
+
- ``done``: final summary with aggregate metrics
|
|
45
|
+
"""
|
|
46
|
+
from znyx_core.engine.streaming import StreamingEvaluator
|
|
47
|
+
from znyx_core.core.models import EvaluationRequest
|
|
48
|
+
|
|
49
|
+
# Resolve policy
|
|
50
|
+
policy = body.policy
|
|
51
|
+
if policy is None and evaluator.policy_resolver:
|
|
52
|
+
policy = evaluator.policy_resolver.resolve(
|
|
53
|
+
tenant_id=body.tenant_id,
|
|
54
|
+
app_id=body.app_id,
|
|
55
|
+
agent_id="default",
|
|
56
|
+
env="prod",
|
|
57
|
+
)
|
|
58
|
+
if policy is None:
|
|
59
|
+
policy = {}
|
|
60
|
+
|
|
61
|
+
request = EvaluationRequest(
|
|
62
|
+
request_id=body.request_id,
|
|
63
|
+
tenant_id=body.tenant_id,
|
|
64
|
+
app_id=body.app_id,
|
|
65
|
+
text="", # text comes in chunks
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
stream_eval = StreamingEvaluator(
|
|
69
|
+
policy=policy,
|
|
70
|
+
context=body.context,
|
|
71
|
+
window_size=body.window_size,
|
|
72
|
+
overlap=body.overlap,
|
|
73
|
+
request=request,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
async def event_generator():
|
|
77
|
+
for chunk in body.chunks:
|
|
78
|
+
events = stream_eval.push(chunk)
|
|
79
|
+
for event in events:
|
|
80
|
+
yield f"event: {event['event']}\ndata: {json.dumps(event['data'])}\n\n"
|
|
81
|
+
if event["event"] == "block":
|
|
82
|
+
# Still send done after block
|
|
83
|
+
break
|
|
84
|
+
if stream_eval.is_blocked:
|
|
85
|
+
break
|
|
86
|
+
|
|
87
|
+
summary = stream_eval.flush()
|
|
88
|
+
yield f"event: {summary['event']}\ndata: {json.dumps(summary['data'])}\n\n"
|
|
89
|
+
hb = get_heartbeat()
|
|
90
|
+
if hb:
|
|
91
|
+
hb.increment_eval_count()
|
|
92
|
+
|
|
93
|
+
return StreamingResponse(
|
|
94
|
+
event_generator(),
|
|
95
|
+
media_type="text/event-stream",
|
|
96
|
+
headers={
|
|
97
|
+
"Cache-Control": "no-cache",
|
|
98
|
+
"Connection": "keep-alive",
|
|
99
|
+
"X-Accel-Buffering": "no",
|
|
100
|
+
},
|
|
101
|
+
)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Egress audit sink.
|
|
2
|
+
|
|
3
|
+
The zero-DB runtime cannot write control-plane tables, but the egress contract requires that every
|
|
4
|
+
boundary-crossing call (remote_llm / remote_api / a hosted inference sidecar) is
|
|
5
|
+
audited — and that a *failed* audit write never silently permits an un-audited
|
|
6
|
+
egress. This module defines the runtime-side abstraction the egress gate calls
|
|
7
|
+
*before* any such call:
|
|
8
|
+
|
|
9
|
+
event = EgressAuditEvent(...metadata only...)
|
|
10
|
+
await sink.record(event) # raises (fail-closed) if it cannot durably record
|
|
11
|
+
# ...only now make the egress call...
|
|
12
|
+
|
|
13
|
+
Backends (selectable, see ``make_audit_sink``):
|
|
14
|
+
* ``SpoolAuditSink`` — durable append-only JSON-lines spool on local disk
|
|
15
|
+
(default ``~/.znyx/egress-audit.spool``). Works without a database, so it
|
|
16
|
+
is valid in OSS/local mode. The control plane drains the spool into
|
|
17
|
+
``egress_events`` rows and hash-chain-signs them.
|
|
18
|
+
* ``NoopAuditSink`` — explicit opt-out (records nothing).
|
|
19
|
+
|
|
20
|
+
Failure semantics: in ``fail_mode="closed"`` (the default) a write failure raises
|
|
21
|
+
``AuditWriteError`` so the caller denies the egress. In ``fail_mode="open"`` the
|
|
22
|
+
failure is logged and swallowed (the egress proceeds un-audited) — only for
|
|
23
|
+
operators who explicitly accept that risk.
|
|
24
|
+
|
|
25
|
+
Privacy: events carry METADATA ONLY (destination host/region, byte count, redaction
|
|
26
|
+
flag, detector key, model/rubric ids) — never raw prompt/response content, mirroring
|
|
27
|
+
the telemetry posture. Audit is NOT gated on GDPR telemetry consent (different lawful
|
|
28
|
+
basis: security/compliance logging).
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import asyncio
|
|
33
|
+
import json
|
|
34
|
+
import logging
|
|
35
|
+
import os
|
|
36
|
+
from abc import ABC, abstractmethod
|
|
37
|
+
from dataclasses import asdict, dataclass
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import List, Optional
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
_DEFAULT_SPOOL = Path.home() / ".znyx" / "egress-audit.spool"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AuditWriteError(RuntimeError):
|
|
47
|
+
"""Raised by a fail-closed sink when an audit event cannot be durably recorded."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class EgressAuditEvent:
|
|
52
|
+
"""A single boundary-crossing audit record. Metadata only — no raw content."""
|
|
53
|
+
occurred_at: str # ISO-8601 timestamp (caller-supplied)
|
|
54
|
+
mode: str # remote_llm | remote_api | local_ml(hosted) | ...
|
|
55
|
+
detector_key: str # e.g. "jailbreak"
|
|
56
|
+
destination_host: str # host the call left the boundary to
|
|
57
|
+
org_scope: Optional[str] = None # tenant/org identifier (no PII)
|
|
58
|
+
trace_id: Optional[str] = None
|
|
59
|
+
destination_region: Optional[str] = None
|
|
60
|
+
bytes_out: int = 0 # size of the payload sent (count, not content)
|
|
61
|
+
redacted: bool = False # was redact_before_egress applied
|
|
62
|
+
allowlisted: bool = False # destination matched the per-detector allowlist
|
|
63
|
+
model_version: Optional[str] = None # model_id@revision the call targeted
|
|
64
|
+
event_id: Optional[str] = None # stable UUID → egress_events PK (idempotent drain)
|
|
65
|
+
|
|
66
|
+
def to_dict(self) -> dict:
|
|
67
|
+
return asdict(self)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class AuditSink(ABC):
|
|
71
|
+
"""Records egress audit events. ``record`` MUST raise in fail-closed mode if it
|
|
72
|
+
cannot durably persist the event, so the caller can deny the egress."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, fail_mode: str = "closed"):
|
|
75
|
+
self.fail_mode = fail_mode if fail_mode in ("closed", "open") else "closed"
|
|
76
|
+
|
|
77
|
+
@abstractmethod
|
|
78
|
+
async def record(self, event: EgressAuditEvent) -> None:
|
|
79
|
+
...
|
|
80
|
+
|
|
81
|
+
@abstractmethod
|
|
82
|
+
def record_sync(self, event: EgressAuditEvent) -> None:
|
|
83
|
+
"""Synchronous durable record, for the (synchronous) escalation gate which
|
|
84
|
+
cannot await. MUST raise in fail-closed mode if it cannot durably persist, so
|
|
85
|
+
the gate denies the egress."""
|
|
86
|
+
...
|
|
87
|
+
|
|
88
|
+
def _handle_failure(self, exc: Exception) -> None:
|
|
89
|
+
if self.fail_mode == "closed":
|
|
90
|
+
raise AuditWriteError(f"egress audit write failed (fail-closed): {exc}") from exc
|
|
91
|
+
logger.error("egress audit write failed (fail-open — egress NOT audited): %s", exc)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class NoopAuditSink(AuditSink):
|
|
95
|
+
"""Records nothing. Explicit opt-out only."""
|
|
96
|
+
|
|
97
|
+
async def record(self, event: EgressAuditEvent) -> None:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
def record_sync(self, event: EgressAuditEvent) -> None:
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class SpoolAuditSink(AuditSink):
|
|
105
|
+
"""Durable append-only JSON-lines spool on local disk.
|
|
106
|
+
|
|
107
|
+
Each ``record`` appends one JSON line and fsyncs before returning, so a recorded
|
|
108
|
+
event survives a crash. The control plane drains and signs the spool.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(self, spool_path: Optional[str] = None, fail_mode: str = "closed"):
|
|
112
|
+
super().__init__(fail_mode=fail_mode)
|
|
113
|
+
self.path = Path(spool_path) if spool_path else _DEFAULT_SPOOL
|
|
114
|
+
self._lock = asyncio.Lock()
|
|
115
|
+
|
|
116
|
+
def _append_sync(self, line: str) -> None:
|
|
117
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
# Append + flush + fsync so the record is durable before we return.
|
|
119
|
+
with open(self.path, "a", encoding="utf-8") as fh:
|
|
120
|
+
fh.write(line + "\n")
|
|
121
|
+
fh.flush()
|
|
122
|
+
os.fsync(fh.fileno())
|
|
123
|
+
|
|
124
|
+
async def record(self, event: EgressAuditEvent) -> None:
|
|
125
|
+
line = json.dumps(event.to_dict(), separators=(",", ":"), sort_keys=True)
|
|
126
|
+
try:
|
|
127
|
+
async with self._lock:
|
|
128
|
+
await asyncio.to_thread(self._append_sync, line)
|
|
129
|
+
except Exception as exc: # noqa: BLE001 — any IO failure must hit the fail-mode gate
|
|
130
|
+
self._handle_failure(exc)
|
|
131
|
+
|
|
132
|
+
def record_sync(self, event: EgressAuditEvent) -> None:
|
|
133
|
+
line = json.dumps(event.to_dict(), separators=(",", ":"), sort_keys=True)
|
|
134
|
+
try:
|
|
135
|
+
self._append_sync(line)
|
|
136
|
+
except Exception as exc: # noqa: BLE001 — any IO failure must hit the fail-mode gate
|
|
137
|
+
self._handle_failure(exc)
|
|
138
|
+
|
|
139
|
+
def read_all(self) -> List[dict]:
|
|
140
|
+
"""Read every spooled event (used by the control-plane drainer / tests).
|
|
141
|
+
|
|
142
|
+
Resilient to a single corrupt/partial line (e.g. an interrupted write): a
|
|
143
|
+
line that won't parse is logged and skipped so one bad record can't abort the
|
|
144
|
+
whole drain."""
|
|
145
|
+
if not self.path.exists():
|
|
146
|
+
return []
|
|
147
|
+
out: List[dict] = []
|
|
148
|
+
for raw in self.path.read_text(encoding="utf-8").splitlines():
|
|
149
|
+
raw = raw.strip()
|
|
150
|
+
if not raw:
|
|
151
|
+
continue
|
|
152
|
+
try:
|
|
153
|
+
out.append(json.loads(raw))
|
|
154
|
+
except json.JSONDecodeError as exc:
|
|
155
|
+
logger.warning("skipping unparseable egress spool line: %s", exc)
|
|
156
|
+
return out
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def make_audit_sink(
|
|
160
|
+
mode: str = "spool",
|
|
161
|
+
fail_mode: str = "closed",
|
|
162
|
+
spool_path: Optional[str] = None,
|
|
163
|
+
) -> AuditSink:
|
|
164
|
+
"""Construct an audit sink from runtime configuration."""
|
|
165
|
+
if mode == "noop":
|
|
166
|
+
return NoopAuditSink(fail_mode=fail_mode)
|
|
167
|
+
if mode == "spool":
|
|
168
|
+
return SpoolAuditSink(spool_path=spool_path, fail_mode=fail_mode)
|
|
169
|
+
logger.warning("Unknown audit_sink_mode %r — defaulting to durable spool", mode)
|
|
170
|
+
return SpoolAuditSink(spool_path=spool_path, fail_mode=fail_mode)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def make_audit_egress_sink(sink: "AuditSink"):
|
|
174
|
+
"""Adapt an ``AuditSink`` to the (synchronous) egress-gate callback the
|
|
175
|
+
escalation engine calls before any boundary-crossing call.
|
|
176
|
+
|
|
177
|
+
The escalation engine passes an ``znyx_core.engine.egress.EgressEvent``; we map
|
|
178
|
+
it to an ``EgressAuditEvent`` and ``record_sync`` it. A fail-closed sink raises
|
|
179
|
+
on a failed write, which the gate turns into a denial (no un-audited egress)."""
|
|
180
|
+
def _sink(ev) -> None:
|
|
181
|
+
sink.record_sync(EgressAuditEvent(
|
|
182
|
+
occurred_at=ev.occurred_at,
|
|
183
|
+
mode=ev.mode,
|
|
184
|
+
detector_key=ev.detector_key,
|
|
185
|
+
destination_host=ev.destination_host or "",
|
|
186
|
+
org_scope=ev.org_scope,
|
|
187
|
+
trace_id=ev.trace_id,
|
|
188
|
+
destination_region=ev.destination_region,
|
|
189
|
+
bytes_out=ev.bytes_out,
|
|
190
|
+
redacted=ev.redacted,
|
|
191
|
+
allowlisted=ev.allowlisted,
|
|
192
|
+
model_version=ev.model_version,
|
|
193
|
+
event_id=ev.event_id,
|
|
194
|
+
))
|
|
195
|
+
return _sink
|