cloakwall 0.1.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.
- cloakwall/__init__.py +0 -0
- cloakwall/audit.py +231 -0
- cloakwall/guardrail.py +392 -0
- cloakwall/redact.py +358 -0
- cloakwall-0.1.0.dist-info/METADATA +199 -0
- cloakwall-0.1.0.dist-info/RECORD +9 -0
- cloakwall-0.1.0.dist-info/WHEEL +5 -0
- cloakwall-0.1.0.dist-info/licenses/LICENSE +661 -0
- cloakwall-0.1.0.dist-info/top_level.txt +1 -0
cloakwall/__init__.py
ADDED
|
File without changes
|
cloakwall/audit.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cloakwall.audit — tamper-evident local audit trail, plus SIEM export.
|
|
3
|
+
|
|
4
|
+
What a compliance officer asks for after an incident is not "were you
|
|
5
|
+
redacting" but "prove what happened on 14 March and prove the record has not
|
|
6
|
+
been edited since". A plain log file cannot answer the second half.
|
|
7
|
+
|
|
8
|
+
Every entry commits to the SHA-256 of the entry before it, so rewriting any
|
|
9
|
+
line breaks the chain from that point on and `verify()` names the line.
|
|
10
|
+
|
|
11
|
+
A bare hash chain does not survive truncation, though: a prefix of a valid
|
|
12
|
+
chain is itself a valid chain, so dropping the last N entries verifies
|
|
13
|
+
clean. Two things close that gap.
|
|
14
|
+
|
|
15
|
+
1. Each entry carries a monotonic sequence number, so gaps in the middle
|
|
16
|
+
are caught even when the hashes are recomputed.
|
|
17
|
+
|
|
18
|
+
2. The head (sequence number plus hash) is mirrored to a separate anchor
|
|
19
|
+
file after every append, and optionally pushed to your SIEM. Verifying
|
|
20
|
+
against the anchor detects truncation, because the anchor still knows
|
|
21
|
+
how many entries there should be.
|
|
22
|
+
|
|
23
|
+
The anchor is only as good as its separation from the log. On the same
|
|
24
|
+
filesystem it stops accidental truncation and a careless attacker; pushed to
|
|
25
|
+
a SIEM under different access control, it stops a determined one. That trust
|
|
26
|
+
boundary is the operator's to choose, so both are supported and neither is
|
|
27
|
+
claimed to be the other.
|
|
28
|
+
|
|
29
|
+
The log never stores the values that were redacted. It stores what kind of
|
|
30
|
+
entity was found and how many -- enough to prove the control was working,
|
|
31
|
+
without becoming a second copy of the data you were protecting.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import hashlib
|
|
37
|
+
import json
|
|
38
|
+
import os
|
|
39
|
+
import socket
|
|
40
|
+
import threading
|
|
41
|
+
import time
|
|
42
|
+
import urllib.error
|
|
43
|
+
import urllib.request
|
|
44
|
+
from datetime import datetime, timezone
|
|
45
|
+
|
|
46
|
+
GENESIS = "0" * 64
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AuditLog:
|
|
50
|
+
def __init__(self, path: str = "./cloakwall-audit.log"):
|
|
51
|
+
self.path = path
|
|
52
|
+
self._lock = threading.Lock() # hooks run concurrently under uvicorn
|
|
53
|
+
os.makedirs(os.path.dirname(os.path.abspath(path)) or ".", exist_ok=True)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def anchor_path(self) -> str:
|
|
57
|
+
return self.path + ".head"
|
|
58
|
+
|
|
59
|
+
def _last(self) -> tuple[int, str]:
|
|
60
|
+
"""Sequence number and hash of the final entry."""
|
|
61
|
+
if not os.path.exists(self.path):
|
|
62
|
+
return 0, GENESIS
|
|
63
|
+
# Read the tail rather than the whole file: this log grows fast and
|
|
64
|
+
# loading it on every request would be O(n) per request.
|
|
65
|
+
with open(self.path, "rb") as fh:
|
|
66
|
+
try:
|
|
67
|
+
fh.seek(-4096, os.SEEK_END)
|
|
68
|
+
except OSError:
|
|
69
|
+
fh.seek(0)
|
|
70
|
+
tail = fh.read().decode("utf-8", "replace").splitlines()
|
|
71
|
+
for line in reversed(tail):
|
|
72
|
+
if line.strip():
|
|
73
|
+
try:
|
|
74
|
+
e = json.loads(line)
|
|
75
|
+
return int(e.get("seq", 0)), e["hash"]
|
|
76
|
+
except Exception:
|
|
77
|
+
continue
|
|
78
|
+
return 0, GENESIS
|
|
79
|
+
|
|
80
|
+
def _write_anchor(self, seq: int, head: str) -> None:
|
|
81
|
+
"""Written atomically: a torn anchor would be worse than none."""
|
|
82
|
+
tmp = self.anchor_path + ".tmp"
|
|
83
|
+
with open(tmp, "w") as fh:
|
|
84
|
+
json.dump({"seq": seq, "hash": head,
|
|
85
|
+
"ts": datetime.now(timezone.utc).isoformat()}, fh)
|
|
86
|
+
fh.flush()
|
|
87
|
+
os.fsync(fh.fileno())
|
|
88
|
+
os.replace(tmp, self.anchor_path)
|
|
89
|
+
|
|
90
|
+
def read_anchor(self) -> dict | None:
|
|
91
|
+
try:
|
|
92
|
+
with open(self.anchor_path) as fh:
|
|
93
|
+
return json.load(fh)
|
|
94
|
+
except (OSError, ValueError):
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
def append(self, event: str, **fields) -> dict:
|
|
98
|
+
with self._lock:
|
|
99
|
+
prev_seq, prev = self._last()
|
|
100
|
+
entry = {
|
|
101
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
102
|
+
"seq": prev_seq + 1,
|
|
103
|
+
"event": event,
|
|
104
|
+
"host": socket.gethostname(),
|
|
105
|
+
"prev": prev,
|
|
106
|
+
**fields,
|
|
107
|
+
}
|
|
108
|
+
body = json.dumps(entry, sort_keys=True)
|
|
109
|
+
entry["hash"] = hashlib.sha256((prev + body).encode()).hexdigest()
|
|
110
|
+
with open(self.path, "a") as fh:
|
|
111
|
+
fh.write(json.dumps(entry, sort_keys=True) + "\n")
|
|
112
|
+
fh.flush()
|
|
113
|
+
os.fsync(fh.fileno()) # survive a pod kill mid-write
|
|
114
|
+
self._write_anchor(entry["seq"], entry["hash"])
|
|
115
|
+
return entry
|
|
116
|
+
|
|
117
|
+
def verify(self, anchor: dict | None = None) -> tuple[bool, str]:
|
|
118
|
+
"""Verify the chain, and if an anchor is available, verify the log has
|
|
119
|
+
not been truncated. Pass an anchor retrieved from your SIEM to check
|
|
120
|
+
against a copy the log's own filesystem cannot reach."""
|
|
121
|
+
if not os.path.exists(self.path):
|
|
122
|
+
return True, "no audit log yet"
|
|
123
|
+
|
|
124
|
+
prev = GENESIS
|
|
125
|
+
expected_seq = 0
|
|
126
|
+
last_hash = GENESIS
|
|
127
|
+
with open(self.path) as fh:
|
|
128
|
+
for n, line in enumerate(fh, 1):
|
|
129
|
+
if not line.strip():
|
|
130
|
+
continue
|
|
131
|
+
e = json.loads(line)
|
|
132
|
+
claimed = e.pop("hash", None)
|
|
133
|
+
if e.get("prev") != prev:
|
|
134
|
+
return False, f"line {n}: chain break"
|
|
135
|
+
expected_seq += 1
|
|
136
|
+
if int(e.get("seq", -1)) != expected_seq:
|
|
137
|
+
return False, (f"line {n}: sequence gap "
|
|
138
|
+
f"(expected {expected_seq}, found {e.get('seq')})")
|
|
139
|
+
recomputed = hashlib.sha256(
|
|
140
|
+
(prev + json.dumps(e, sort_keys=True)).encode()).hexdigest()
|
|
141
|
+
if recomputed != claimed:
|
|
142
|
+
return False, f"line {n}: entry altered after writing"
|
|
143
|
+
prev = last_hash = claimed
|
|
144
|
+
|
|
145
|
+
a = anchor if anchor is not None else self.read_anchor()
|
|
146
|
+
if a:
|
|
147
|
+
if expected_seq < int(a.get("seq", 0)):
|
|
148
|
+
return False, (f"truncated: anchor records {a['seq']} entries, "
|
|
149
|
+
f"log holds {expected_seq}")
|
|
150
|
+
if expected_seq == int(a.get("seq", 0)) and last_hash != a.get("hash"):
|
|
151
|
+
return False, "head does not match anchor"
|
|
152
|
+
return True, f"chain intact, {expected_seq} entries, anchor matches"
|
|
153
|
+
return True, f"chain intact, {expected_seq} entries (no anchor to check truncation)"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ------------------------------------------------------------------ #
|
|
157
|
+
# SIEM export #
|
|
158
|
+
# #
|
|
159
|
+
# Fire-and-forget on a worker thread. An audit sink that is slow or #
|
|
160
|
+
# down must never add latency to, or fail, an LLM request -- the log #
|
|
161
|
+
# on disk is the system of record and the SIEM is a copy. #
|
|
162
|
+
# ------------------------------------------------------------------ #
|
|
163
|
+
|
|
164
|
+
class SIEMExporter:
|
|
165
|
+
def __init__(self, kind: str | None = None, url: str = "", token: str = "",
|
|
166
|
+
timeout: float = 3.0, verify_tls: bool = True):
|
|
167
|
+
self.kind = (kind or "").lower()
|
|
168
|
+
self.url = url
|
|
169
|
+
self.token = token
|
|
170
|
+
self.timeout = timeout
|
|
171
|
+
self.verify_tls = verify_tls
|
|
172
|
+
self.failures = 0
|
|
173
|
+
|
|
174
|
+
@classmethod
|
|
175
|
+
def from_env(cls) -> "SIEMExporter":
|
|
176
|
+
return cls(
|
|
177
|
+
kind=os.environ.get("CLOAKWALL_SIEM", ""),
|
|
178
|
+
url=os.environ.get("CLOAKWALL_SIEM_URL", ""),
|
|
179
|
+
token=os.environ.get("CLOAKWALL_SIEM_TOKEN", ""),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def enabled(self) -> bool:
|
|
184
|
+
return bool(self.kind and self.url and self.token)
|
|
185
|
+
|
|
186
|
+
def _payload(self, entry: dict) -> tuple[bytes, dict]:
|
|
187
|
+
if self.kind == "splunk":
|
|
188
|
+
body = {
|
|
189
|
+
"time": time.time(),
|
|
190
|
+
"host": entry.get("host"),
|
|
191
|
+
"source": "cloakwall",
|
|
192
|
+
"sourcetype": "_json",
|
|
193
|
+
"event": entry,
|
|
194
|
+
}
|
|
195
|
+
return json.dumps(body).encode(), {
|
|
196
|
+
"Authorization": f"Splunk {self.token}",
|
|
197
|
+
"Content-Type": "application/json",
|
|
198
|
+
}
|
|
199
|
+
if self.kind == "datadog":
|
|
200
|
+
body = [{
|
|
201
|
+
"ddsource": "cloakwall",
|
|
202
|
+
"service": "cloakwall",
|
|
203
|
+
"hostname": entry.get("host"),
|
|
204
|
+
"message": json.dumps(entry),
|
|
205
|
+
}]
|
|
206
|
+
return json.dumps(body).encode(), {
|
|
207
|
+
"DD-API-KEY": self.token,
|
|
208
|
+
"Content-Type": "application/json",
|
|
209
|
+
}
|
|
210
|
+
# elasticsearch and anything else that accepts a bare JSON document
|
|
211
|
+
return json.dumps(entry).encode(), {
|
|
212
|
+
"Authorization": f"Bearer {self.token}",
|
|
213
|
+
"Content-Type": "application/json",
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
def _post(self, entry: dict) -> None:
|
|
217
|
+
try:
|
|
218
|
+
data, headers = self._payload(entry)
|
|
219
|
+
req = urllib.request.Request(self.url, data=data, headers=headers)
|
|
220
|
+
ctx = None
|
|
221
|
+
if not self.verify_tls:
|
|
222
|
+
import ssl
|
|
223
|
+
ctx = ssl._create_unverified_context() # self-signed on-prem SIEM
|
|
224
|
+
urllib.request.urlopen(req, timeout=self.timeout, context=ctx).read()
|
|
225
|
+
except (urllib.error.URLError, OSError, ValueError):
|
|
226
|
+
self.failures += 1 # counted and surfaced in /healthz, never raised
|
|
227
|
+
|
|
228
|
+
def send(self, entry: dict) -> None:
|
|
229
|
+
if not self.enabled:
|
|
230
|
+
return
|
|
231
|
+
threading.Thread(target=self._post, args=(entry,), daemon=True).start()
|
cloakwall/guardrail.py
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cloakwall.guardrail — the LiteLLM adapter.
|
|
3
|
+
|
|
4
|
+
LiteLLM's OSS guardrail framework lets you register a CustomGuardrail without
|
|
5
|
+
an enterprise licence. This class implements the hooks that matter:
|
|
6
|
+
|
|
7
|
+
async_pre_call_hook runs before the request leaves. This is where
|
|
8
|
+
redaction has to happen -- LiteLLM documents
|
|
9
|
+
that pre_call is the mode to use when you
|
|
10
|
+
modify content, because during_call runs in
|
|
11
|
+
parallel with the LLM call and your edits may
|
|
12
|
+
not land before the request goes out.
|
|
13
|
+
Covers messages, the top-level system field,
|
|
14
|
+
prompt (completions, image generation) and
|
|
15
|
+
input (embeddings, Responses API).
|
|
16
|
+
|
|
17
|
+
async_post_call_success_hook runs on a non-streamed response. Always
|
|
18
|
+
audited; redacted too when redact_output is
|
|
19
|
+
on, so PII the model generates is masked
|
|
20
|
+
before the caller sees it.
|
|
21
|
+
|
|
22
|
+
async_post_call_streaming_iterator_hook
|
|
23
|
+
runs on a streamed response, chunk by chunk.
|
|
24
|
+
With redact_output on, text and tool-call
|
|
25
|
+
arguments go through a StreamRedactor so an
|
|
26
|
+
entity split across chunks is still caught.
|
|
27
|
+
(LiteLLM runs the success hook on streams
|
|
28
|
+
only after delivery, too late to redact.)
|
|
29
|
+
|
|
30
|
+
Config (litellm config.yaml):
|
|
31
|
+
|
|
32
|
+
guardrails:
|
|
33
|
+
- guardrail_name: cloakwall
|
|
34
|
+
litellm_params:
|
|
35
|
+
guardrail: cloakwall.guardrail.Cloakwall
|
|
36
|
+
mode: [pre_call, post_call] # post_call only needed for redact_output
|
|
37
|
+
redaction_mode: mask # mask | hash | partial
|
|
38
|
+
redact_output: true # also mask PII in model responses
|
|
39
|
+
audit_path: /var/log/cloakwall/audit.log
|
|
40
|
+
fail_closed: true
|
|
41
|
+
|
|
42
|
+
Everything runs in-process. No sidecar, no external endpoint, no model
|
|
43
|
+
download -- which is the point: it works unchanged in an air-gapped cluster.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import copy
|
|
49
|
+
import os
|
|
50
|
+
from typing import Any, AsyncGenerator, Optional, Union
|
|
51
|
+
|
|
52
|
+
from .audit import AuditLog, SIEMExporter
|
|
53
|
+
from .redact import Redaction, Redactor, StreamRedactor
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
from litellm.integrations.custom_guardrail import CustomGuardrail
|
|
57
|
+
except ImportError: # allows the core to be imported and tested standalone
|
|
58
|
+
class CustomGuardrail: # type: ignore
|
|
59
|
+
def __init__(self, **kwargs): pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# Request fields that carry text, across every endpoint the proxy forwards.
|
|
63
|
+
# Anything else in the request body (model, tools, temperature...) is config.
|
|
64
|
+
REQUEST_FIELDS = ("messages", "system", "prompt", "input")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Cloakwall(CustomGuardrail):
|
|
68
|
+
def __init__(self,
|
|
69
|
+
redaction_mode: str = "mask",
|
|
70
|
+
entities: Optional[list] = None,
|
|
71
|
+
audit_path: str = "./cloakwall-audit.log",
|
|
72
|
+
fail_closed: bool = True,
|
|
73
|
+
anchor_every: int = 100,
|
|
74
|
+
block_on: Optional[list] = None,
|
|
75
|
+
redact_output: bool = False,
|
|
76
|
+
stream_holdback: int = 64,
|
|
77
|
+
**kwargs):
|
|
78
|
+
self.redactor = Redactor(
|
|
79
|
+
mode=redaction_mode,
|
|
80
|
+
entities=set(entities) if entities else None,
|
|
81
|
+
)
|
|
82
|
+
self.audit = AuditLog(audit_path)
|
|
83
|
+
self.siem = SIEMExporter.from_env()
|
|
84
|
+
# fail_closed decides what happens if redaction itself throws. In a
|
|
85
|
+
# regulated deployment the safe answer is to reject the request rather
|
|
86
|
+
# than forward text that may not have been scrubbed.
|
|
87
|
+
self.fail_closed = fail_closed
|
|
88
|
+
self.anchor_every = max(1, int(anchor_every))
|
|
89
|
+
# Entities that must never reach the model at all. Redaction is the
|
|
90
|
+
# default because it keeps the request useful, but some policies want
|
|
91
|
+
# the call rejected outright -- a card number in a tool-call argument
|
|
92
|
+
# is a bug upstream, and silently masking it hides the bug.
|
|
93
|
+
self.block_on = set(block_on or [])
|
|
94
|
+
# Off by default: masking output changes what callers receive, and an
|
|
95
|
+
# app that asks the model to draft an email to a known address would
|
|
96
|
+
# break. Turn it on where the model may echo or invent identifiers.
|
|
97
|
+
self.redact_output = bool(redact_output)
|
|
98
|
+
self.stream_holdback = int(stream_holdback)
|
|
99
|
+
self.blocked = 0
|
|
100
|
+
self.requests = 0
|
|
101
|
+
self.redacted = 0
|
|
102
|
+
self.responses_redacted = 0
|
|
103
|
+
super().__init__(**kwargs)
|
|
104
|
+
|
|
105
|
+
def _record(self, event: str, **fields) -> None:
|
|
106
|
+
entry = self.audit.append(event, **fields)
|
|
107
|
+
self.siem.send(entry)
|
|
108
|
+
# Mirror the chain head to the SIEM on a cadence. The SIEM sits under
|
|
109
|
+
# different access control from the log's filesystem, so an anchor
|
|
110
|
+
# held there is what makes end-truncation detectable by someone who
|
|
111
|
+
# can write to the pod. Every N entries rather than every entry, to
|
|
112
|
+
# keep the export volume sane.
|
|
113
|
+
if self.siem.enabled and entry["seq"] % self.anchor_every == 0:
|
|
114
|
+
self.siem.send({
|
|
115
|
+
"event": "cloakwall.anchor",
|
|
116
|
+
"seq": entry["seq"],
|
|
117
|
+
"hash": entry["hash"],
|
|
118
|
+
"ts": entry["ts"],
|
|
119
|
+
"host": entry["host"],
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
async def async_pre_call_hook(self, user_api_key_dict: Any, cache: Any,
|
|
123
|
+
data: dict, call_type: Any
|
|
124
|
+
) -> Optional[Union[Exception, str, dict]]:
|
|
125
|
+
self.requests += 1
|
|
126
|
+
present = [f for f in REQUEST_FIELDS if data.get(f) is not None]
|
|
127
|
+
if not present:
|
|
128
|
+
return data
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
cleaned, redactions = {}, []
|
|
132
|
+
for f in present:
|
|
133
|
+
if f == "messages" and isinstance(data[f], list):
|
|
134
|
+
cleaned[f], r = self.redactor.redact_messages(data[f])
|
|
135
|
+
else:
|
|
136
|
+
cleaned[f], r = self.redactor.redact_value(data[f])
|
|
137
|
+
redactions.extend(r)
|
|
138
|
+
except Exception as exc:
|
|
139
|
+
self._record("cloakwall.error", stage="pre_call", error=str(exc))
|
|
140
|
+
if self.fail_closed:
|
|
141
|
+
raise
|
|
142
|
+
return data
|
|
143
|
+
|
|
144
|
+
hits = {r.entity for r in redactions}
|
|
145
|
+
blocked = hits & self.block_on
|
|
146
|
+
if blocked:
|
|
147
|
+
self.blocked += 1
|
|
148
|
+
self._record("cloakwall.blocked",
|
|
149
|
+
model=data.get("model"),
|
|
150
|
+
key_alias=getattr(user_api_key_dict, "key_alias", None),
|
|
151
|
+
entities=_counts(redactions), blocked_on=sorted(blocked))
|
|
152
|
+
raise ValueError(
|
|
153
|
+
"Cloakwall blocked this request: "
|
|
154
|
+
f"{', '.join(sorted(blocked))} present in prompt or tool-call arguments")
|
|
155
|
+
|
|
156
|
+
if redactions:
|
|
157
|
+
self.redacted += 1
|
|
158
|
+
data.update(cleaned)
|
|
159
|
+
# Note what was found, never what it was.
|
|
160
|
+
self._record(
|
|
161
|
+
"cloakwall.redacted",
|
|
162
|
+
model=data.get("model"),
|
|
163
|
+
key_alias=getattr(user_api_key_dict, "key_alias", None),
|
|
164
|
+
team_id=getattr(user_api_key_dict, "team_id", None),
|
|
165
|
+
entities=_counts(redactions),
|
|
166
|
+
total=len(redactions),
|
|
167
|
+
mode=self.redactor.mode,
|
|
168
|
+
)
|
|
169
|
+
return data
|
|
170
|
+
|
|
171
|
+
async def async_post_call_success_hook(self, data: dict,
|
|
172
|
+
user_api_key_dict: Any,
|
|
173
|
+
response: Any) -> Any:
|
|
174
|
+
# A streamed response was already redacted chunk by chunk; LiteLLM
|
|
175
|
+
# hands this hook the assembled copy after delivery.
|
|
176
|
+
if self.redact_output and not data.get("stream"):
|
|
177
|
+
try:
|
|
178
|
+
redactions = self._redact_response(response)
|
|
179
|
+
except Exception as exc:
|
|
180
|
+
self._record("cloakwall.error", stage="post_call", error=str(exc))
|
|
181
|
+
if self.fail_closed:
|
|
182
|
+
raise
|
|
183
|
+
redactions = []
|
|
184
|
+
self._record_output(data, user_api_key_dict, redactions, stream=False)
|
|
185
|
+
self._record(
|
|
186
|
+
"cloakwall.response",
|
|
187
|
+
model=data.get("model"),
|
|
188
|
+
key_alias=getattr(user_api_key_dict, "key_alias", None),
|
|
189
|
+
finish_reason=_finish_reason(response),
|
|
190
|
+
)
|
|
191
|
+
return response
|
|
192
|
+
|
|
193
|
+
async def async_post_call_streaming_iterator_hook(
|
|
194
|
+
self, user_api_key_dict: Any, response: Any, request_data: dict
|
|
195
|
+
) -> AsyncGenerator[Any, None]:
|
|
196
|
+
if not self.redact_output:
|
|
197
|
+
async for chunk in response:
|
|
198
|
+
yield chunk
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
# One StreamRedactor per text stream: each choice's content, and each
|
|
202
|
+
# tool call's arguments, arrive as separate interleaved fragments.
|
|
203
|
+
streams: dict[tuple, StreamRedactor] = {}
|
|
204
|
+
templates: dict[tuple, Any] = {}
|
|
205
|
+
redactions: list[Redaction] = []
|
|
206
|
+
|
|
207
|
+
def feed(key, chunk, text):
|
|
208
|
+
sr = streams.get(key)
|
|
209
|
+
if sr is None:
|
|
210
|
+
sr = streams[key] = StreamRedactor(self.redactor, self.stream_holdback)
|
|
211
|
+
templates[key] = chunk
|
|
212
|
+
r = sr.feed(text)
|
|
213
|
+
redactions.extend(r.redactions)
|
|
214
|
+
return r.text
|
|
215
|
+
|
|
216
|
+
def flush(choice_index=None):
|
|
217
|
+
# Held-back text goes out in a chunk of its own, cloned from the
|
|
218
|
+
# last chunk that carried that stream, before the finish chunk.
|
|
219
|
+
out = []
|
|
220
|
+
for key in list(streams):
|
|
221
|
+
if choice_index is not None and key[1] != choice_index:
|
|
222
|
+
continue
|
|
223
|
+
sr = streams.pop(key)
|
|
224
|
+
template = templates.pop(key)
|
|
225
|
+
if not sr.pending:
|
|
226
|
+
continue
|
|
227
|
+
r = sr.flush()
|
|
228
|
+
redactions.extend(r.redactions)
|
|
229
|
+
out.append(_flush_chunk(template, key, r.text))
|
|
230
|
+
return out
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
async for chunk in response:
|
|
234
|
+
finishing = []
|
|
235
|
+
for ch in _get(chunk, "choices") or []:
|
|
236
|
+
idx = _get(ch, "index") or 0
|
|
237
|
+
delta = _get(ch, "delta")
|
|
238
|
+
if delta is not None:
|
|
239
|
+
text = _get(delta, "content")
|
|
240
|
+
if isinstance(text, str) and text:
|
|
241
|
+
_set(delta, "content", feed(("content", idx), chunk, text))
|
|
242
|
+
for tc in _get(delta, "tool_calls") or []:
|
|
243
|
+
fn = _get(tc, "function")
|
|
244
|
+
args = _get(fn, "arguments") if fn is not None else None
|
|
245
|
+
if isinstance(args, str) and args:
|
|
246
|
+
key = ("tool", idx, _get(tc, "index") or 0)
|
|
247
|
+
_set(fn, "arguments", feed(key, chunk, args))
|
|
248
|
+
if _get(ch, "finish_reason"):
|
|
249
|
+
finishing.append(idx)
|
|
250
|
+
for idx in finishing:
|
|
251
|
+
for extra in flush(idx):
|
|
252
|
+
yield extra
|
|
253
|
+
yield chunk
|
|
254
|
+
for extra in flush():
|
|
255
|
+
yield extra
|
|
256
|
+
except Exception as exc:
|
|
257
|
+
self._record("cloakwall.error", stage="stream", error=str(exc))
|
|
258
|
+
raise
|
|
259
|
+
finally:
|
|
260
|
+
self._record_output(request_data, user_api_key_dict, redactions, stream=True)
|
|
261
|
+
|
|
262
|
+
def _redact_response(self, response: Any) -> list[Redaction]:
|
|
263
|
+
"""Mask PII in a completed response, in place. Handles LiteLLM's
|
|
264
|
+
ModelResponse objects and plain dicts: chat choices (content and
|
|
265
|
+
tool-call arguments), text-completion choices, and Anthropic-style
|
|
266
|
+
content blocks."""
|
|
267
|
+
reds: list[Redaction] = []
|
|
268
|
+
for ch in _get(response, "choices") or []:
|
|
269
|
+
msg = _get(ch, "message")
|
|
270
|
+
if msg is not None:
|
|
271
|
+
reds += self._redact_field(msg, "content")
|
|
272
|
+
for tc in _get(msg, "tool_calls") or []:
|
|
273
|
+
fn = _get(tc, "function")
|
|
274
|
+
if fn is not None:
|
|
275
|
+
reds += self._redact_field(fn, "arguments")
|
|
276
|
+
reds += self._redact_field(ch, "text")
|
|
277
|
+
content = _get(response, "content")
|
|
278
|
+
if isinstance(content, list):
|
|
279
|
+
for block in content:
|
|
280
|
+
kind = _get(block, "type")
|
|
281
|
+
if kind == "text":
|
|
282
|
+
reds += self._redact_field(block, "text")
|
|
283
|
+
elif kind == "tool_use":
|
|
284
|
+
new, r = self.redactor.redact_value(
|
|
285
|
+
{"type": "tool_use", "input": _get(block, "input")})
|
|
286
|
+
if r:
|
|
287
|
+
_set(block, "input", new["input"])
|
|
288
|
+
reds += r
|
|
289
|
+
return reds
|
|
290
|
+
|
|
291
|
+
def _redact_field(self, obj: Any, key: str) -> list[Redaction]:
|
|
292
|
+
value = _get(obj, key)
|
|
293
|
+
if value is None:
|
|
294
|
+
return []
|
|
295
|
+
new, r = self.redactor.redact_value(value)
|
|
296
|
+
if r:
|
|
297
|
+
_set(obj, key, new)
|
|
298
|
+
return r
|
|
299
|
+
|
|
300
|
+
def _record_output(self, data: dict, user_api_key_dict: Any,
|
|
301
|
+
redactions: list[Redaction], stream: bool) -> None:
|
|
302
|
+
if not redactions:
|
|
303
|
+
return
|
|
304
|
+
self.responses_redacted += 1
|
|
305
|
+
self._record(
|
|
306
|
+
"cloakwall.redacted_output",
|
|
307
|
+
model=(data or {}).get("model"),
|
|
308
|
+
key_alias=getattr(user_api_key_dict, "key_alias", None),
|
|
309
|
+
team_id=getattr(user_api_key_dict, "team_id", None),
|
|
310
|
+
entities=_counts(redactions),
|
|
311
|
+
total=len(redactions),
|
|
312
|
+
mode=self.redactor.mode,
|
|
313
|
+
stream=stream,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
def stats(self) -> dict:
|
|
317
|
+
ok, msg = self.audit.verify()
|
|
318
|
+
anchor = self.audit.read_anchor()
|
|
319
|
+
return {
|
|
320
|
+
"requests": self.requests,
|
|
321
|
+
"requests_with_redactions": self.redacted,
|
|
322
|
+
"requests_blocked": self.blocked,
|
|
323
|
+
"responses_with_redactions": self.responses_redacted,
|
|
324
|
+
"redact_output": self.redact_output,
|
|
325
|
+
"block_on": sorted(self.block_on),
|
|
326
|
+
"redaction_mode": self.redactor.mode,
|
|
327
|
+
"entities_enabled": sorted(self.redactor.entities),
|
|
328
|
+
"audit_chain": msg,
|
|
329
|
+
"audit_intact": ok,
|
|
330
|
+
"audit_entries": (anchor or {}).get("seq", 0),
|
|
331
|
+
"anchor_every": self.anchor_every,
|
|
332
|
+
"siem": self.siem.kind if self.siem.enabled else "disabled",
|
|
333
|
+
"siem_failures": self.siem.failures,
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _counts(redactions: list[Redaction]) -> dict[str, int]:
|
|
338
|
+
out: dict[str, int] = {}
|
|
339
|
+
for r in redactions:
|
|
340
|
+
out[r.entity] = out.get(r.entity, 0) + 1
|
|
341
|
+
return out
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _get(obj: Any, key: str) -> Any:
|
|
345
|
+
"""Field access that works on LiteLLM's response objects and on dicts."""
|
|
346
|
+
if isinstance(obj, dict):
|
|
347
|
+
return obj.get(key)
|
|
348
|
+
return getattr(obj, key, None)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _set(obj: Any, key: str, value: Any) -> None:
|
|
352
|
+
if isinstance(obj, dict):
|
|
353
|
+
obj[key] = value
|
|
354
|
+
else:
|
|
355
|
+
setattr(obj, key, value)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _flush_chunk(template: Any, key: tuple, text: str) -> Any:
|
|
359
|
+
"""A copy of `template` carrying only the flushed text for one stream:
|
|
360
|
+
a single choice, no finish_reason, and for tool calls only the arguments
|
|
361
|
+
(id and name were already sent; repeating them makes clients that
|
|
362
|
+
concatenate deltas double the function name)."""
|
|
363
|
+
chunk = copy.deepcopy(template)
|
|
364
|
+
choice_index = key[1]
|
|
365
|
+
choices = [c for c in (_get(chunk, "choices") or [])
|
|
366
|
+
if (_get(c, "index") or 0) == choice_index]
|
|
367
|
+
_set(chunk, "choices", choices[:1])
|
|
368
|
+
for ch in choices[:1]:
|
|
369
|
+
_set(ch, "finish_reason", None)
|
|
370
|
+
delta = _get(ch, "delta")
|
|
371
|
+
if key[0] == "content":
|
|
372
|
+
_set(delta, "content", text)
|
|
373
|
+
_set(delta, "tool_calls", None)
|
|
374
|
+
else:
|
|
375
|
+
tool_index = key[2]
|
|
376
|
+
calls = [tc for tc in (_get(delta, "tool_calls") or [])
|
|
377
|
+
if (_get(tc, "index") or 0) == tool_index]
|
|
378
|
+
for tc in calls[:1]:
|
|
379
|
+
_set(tc, "id", None)
|
|
380
|
+
fn = _get(tc, "function")
|
|
381
|
+
_set(fn, "name", None)
|
|
382
|
+
_set(fn, "arguments", text)
|
|
383
|
+
_set(delta, "tool_calls", calls[:1])
|
|
384
|
+
_set(delta, "content", None)
|
|
385
|
+
return chunk
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _finish_reason(response: Any) -> Optional[str]:
|
|
389
|
+
try:
|
|
390
|
+
return response.choices[0].finish_reason
|
|
391
|
+
except Exception:
|
|
392
|
+
return None
|