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.
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