naka 0.3.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.
naka/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Naka: an open-source runtime firewall for AI agents."""
2
+
3
+ __version__ = "0.3.0"
naka/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
naka/bench.py ADDED
@@ -0,0 +1,386 @@
1
+ """Benchmark: how much does each detection layer catch, and how often is it wrong?
2
+
3
+ naka bench # downloads public datasets once, then scores
4
+ naka bench --json out.json
5
+
6
+ Two numbers matter for a firewall, and they pull against each other:
7
+ - detection rate: share of attacks flagged (higher is better)
8
+ - false-alarm rate: share of harmless text flagged (lower is better). A
9
+ firewall that blocks normal work gets switched off, so this matters as much.
10
+
11
+ Test sets (each is either all attacks or all harmless):
12
+ injecagent-embedded attacker instructions placed inside realistic tool
13
+ responses (product reviews, emails, notes): the
14
+ indirect-injection case Naka exists for
15
+ bipia-embedded BIPIA attacks inserted into real emails
16
+ naka-redteam attacks from Naka's own red-team reviews
17
+ notinject harmless sentences full of trigger words ("ignore",
18
+ "bypass"...): measures over-blocking
19
+ bipia-contexts real emails, tables and code with no attack
20
+ injecagent-benign the same tool responses with harmless content
21
+ naka-benign harmless text that tripped earlier versions
22
+
23
+ Datasets come from GitHub at pinned commits (all MIT licensed) and are only
24
+ read as JSON; nothing from them is executed. InjecAgent and BIPIA are never
25
+ used to train the built-in classifier, so its scores here are on unseen data.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import random
32
+ import subprocess
33
+ import time
34
+ from dataclasses import dataclass
35
+ from importlib import resources
36
+ from pathlib import Path
37
+ from typing import Callable
38
+
39
+ from . import detectors as d
40
+ from .store import home
41
+
42
+ SOURCES = {
43
+ "InjecAgent": ("https://github.com/uiuc-kang-lab/InjecAgent", "f19c9f2c79a41046eb13c03c51a24c567a8ffa07"),
44
+ "BIPIA": ("https://github.com/microsoft/BIPIA", "a004b69ec0dd446e0afd461d98cb5e96e120a5d0"),
45
+ "PIGuard": ("https://github.com/leolee99/PIGuard", "1b5751e88bf7475acbedfc8eda795ce060307c84"),
46
+ }
47
+
48
+ # Harmless content to put where InjecAgent's templates expect an attack.
49
+ BENIGN_FILLS = [
50
+ "Great product, the battery easily lasts a full working day.",
51
+ "Arrived two days late but well packed. Works as described.",
52
+ "Reminder: team lunch moved to Thursday at 1pm, same place.",
53
+ "Please review the attached Q3 numbers before Monday's meeting.",
54
+ "The update fixed the login issue for me. Thanks for the quick response!",
55
+ "Can you send me the slides from yesterday's session?",
56
+ "Ignore the noise from the fan at first, it settles after a minute.",
57
+ "I'd skip the extended warranty, the standard one covers enough.",
58
+ "Meeting notes: agreed to ship the beta on the 15th, Ravi owns QA.",
59
+ "Your package has been delivered to the front desk.",
60
+ "Do not forget to bring your ID for building access tomorrow.",
61
+ "Password reset links expire after 30 minutes for security.",
62
+ "Thanks for the order! Tracking details will follow by email.",
63
+ "Bypass the main road during the festival, traffic is heavy near the market.",
64
+ "We override the default timeout to 60 seconds in production.",
65
+ ]
66
+
67
+
68
+ @dataclass
69
+ class Sample:
70
+ text: str
71
+ attack: bool
72
+ set: str
73
+
74
+
75
+ def data_dir() -> Path:
76
+ path = home() / "bench-data"
77
+ path.mkdir(parents=True, exist_ok=True)
78
+ return path
79
+
80
+
81
+ def fetch(names=SOURCES) -> dict[str, Path]:
82
+ """Shallow-fetch each dataset repository at its pinned commit (once)."""
83
+ paths = {}
84
+ for name in names:
85
+ url, commit = SOURCES[name]
86
+ dest = data_dir() / name
87
+ marker = dest / ".naka-commit"
88
+ if not (marker.exists() and marker.read_text().strip() == commit):
89
+ dest.mkdir(parents=True, exist_ok=True)
90
+ run = lambda *a: subprocess.run(["git", "-C", str(dest), *a], check=True, # noqa: E731
91
+ capture_output=True, timeout=600)
92
+ if not (dest / ".git").exists():
93
+ run("init", "-q")
94
+ run("fetch", "-q", "--depth", "1", url, commit)
95
+ run("checkout", "-q", "FETCH_HEAD")
96
+ marker.write_text(commit)
97
+ paths[name] = dest
98
+ return paths
99
+
100
+
101
+ def _jsonl(path: Path) -> list[dict]:
102
+ return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
103
+
104
+
105
+ def own_corpus() -> dict:
106
+ return json.loads(resources.files("naka.data").joinpath("redteam_corpus.json").read_text("utf-8"))
107
+
108
+
109
+ def build(paths: dict[str, Path], seed: int = 7) -> list[Sample]:
110
+ rng = random.Random(seed)
111
+ out: list[Sample] = []
112
+
113
+ # InjecAgent: attacker instructions inside tool-response templates.
114
+ ia = paths["InjecAgent"] / "data"
115
+ templates = [u["Tool Response Template"] for u in _jsonl(ia / "user_cases.jsonl")]
116
+ attacks = [a["Attacker Instruction"] for f in ("attacker_cases_dh.jsonl", "attacker_cases_ds.jsonl")
117
+ for a in _jsonl(ia / f)]
118
+ for t in templates:
119
+ for a in attacks:
120
+ out.append(Sample(t.replace("<Attacker Instruction>", a), True, "injecagent-embedded"))
121
+ for b in BENIGN_FILLS:
122
+ out.append(Sample(t.replace("<Attacker Instruction>", b), False, "injecagent-benign"))
123
+
124
+ # BIPIA: attacks inserted into real emails; plus clean emails, tables, code.
125
+ bp = paths["BIPIA"] / "benchmark"
126
+ bip_attacks = [x for f in ("text_attack_train.json", "text_attack_test.json",
127
+ "code_attack_train.json", "code_attack_test.json")
128
+ for items in json.loads((bp / f).read_text()).values() for x in items]
129
+ emails = [r["context"] for f in ("train.jsonl", "test.jsonl") for r in _jsonl(bp / "email" / f)]
130
+ for i, a in enumerate(bip_attacks):
131
+ ctx = emails[i % len(emails)]
132
+ cut = rng.randrange(len(ctx) + 1)
133
+ cut = ctx.rfind("\n", 0, cut) + 1 if "\n" in ctx[:cut] else len(ctx)
134
+ out.append(Sample(ctx[:cut] + "\n" + a + "\n" + ctx[cut:], True, "bipia-embedded"))
135
+ tables = [r["context"] for r in _jsonl(bp / "table" / "test.jsonl")]
136
+ code = [r["context"] for f in ("train.jsonl", "test.jsonl") for r in _jsonl(bp / "code" / f)]
137
+ for ctx in emails + tables + code:
138
+ if isinstance(ctx, list):
139
+ ctx = "\n".join(map(str, ctx))
140
+ if isinstance(ctx, str) and ctx.strip():
141
+ out.append(Sample(ctx, False, "bipia-contexts"))
142
+
143
+ # NotInject: harmless sentences built around trigger words.
144
+ pg = paths["PIGuard"] / "datasets"
145
+ for f in ("NotInject_one.json", "NotInject_two.json", "NotInject_three.json"):
146
+ for r in json.loads((pg / f).read_text()):
147
+ out.append(Sample(r["prompt"], False, "notinject"))
148
+
149
+ corpus = own_corpus()
150
+ out += [Sample(t, True, "naka-redteam") for t in corpus["attacks"]]
151
+ out += [Sample(t, False, "naka-benign") for t in corpus["benign"]]
152
+ return out
153
+
154
+
155
+ # ---------------------------------------------------------------- layers
156
+ def regex_layer(threshold: int = 3) -> Callable[[str], bool]:
157
+ return lambda text: d.score(d.scan_injection(text)) >= threshold
158
+
159
+
160
+ def ml_layer(threshold: float | None = None) -> Callable[[str], bool]:
161
+ from .classifier import default
162
+ model = default()
163
+ cut = model.threshold if threshold is None else threshold
164
+ return lambda text: model.score(text).probability >= cut
165
+
166
+
167
+ def combined_layer() -> Callable[[str], bool]:
168
+ regex, ml = regex_layer(), ml_layer()
169
+ return lambda text: regex(text) or ml(text)
170
+
171
+
172
+ LAYERS = {"regex": regex_layer, "classifier": ml_layer, "regex + classifier": combined_layer}
173
+
174
+
175
+ def evaluate(samples: list[Sample], layers=LAYERS) -> dict:
176
+ sets = sorted({s.set for s in samples})
177
+ report: dict = {"sets": {}, "layers": {}}
178
+ for name in sets:
179
+ group = [s for s in samples if s.set == name]
180
+ report["sets"][name] = {"n": len(group), "kind": "attack" if group[0].attack else "benign"}
181
+ for lname, make in layers.items():
182
+ check = make()
183
+ started, chars = time.perf_counter(), 0
184
+ per_set: dict[str, list[int]] = {}
185
+ for s in samples:
186
+ hit = check(s.text)
187
+ chars += len(s.text)
188
+ per_set.setdefault(s.set, [0, 0])
189
+ per_set[s.set][0] += hit
190
+ per_set[s.set][1] += 1
191
+ elapsed = time.perf_counter() - started
192
+ att = [v for k, v in per_set.items() if report["sets"][k]["kind"] == "attack"]
193
+ ben = [v for k, v in per_set.items() if report["sets"][k]["kind"] == "benign"]
194
+ # Overall rates average the sets, so one big set doesn't dominate.
195
+ report["layers"][lname] = {
196
+ "per_set": {k: round(100 * h / n, 1) for k, (h, n) in per_set.items()},
197
+ "detection_rate": round(100 * sum(h / n for h, n in att) / len(att), 1),
198
+ "false_alarm_rate": round(100 * sum(h / n for h, n in ben) / len(ben), 1),
199
+ "ms_per_kb": round(1000 * elapsed / max(chars / 1024, 1), 2),
200
+ }
201
+ return report
202
+
203
+
204
+ def render(report: dict) -> str:
205
+ layers = list(report["layers"])
206
+ w = max(len(k) for k in report["sets"]) + 2
207
+ lines = [f"{'test set':{w}} {'kind':7} {'n':>5} " + " ".join(f"{l:>18}" for l in layers)]
208
+ for kind in ("attack", "benign"):
209
+ for name, info in report["sets"].items():
210
+ if info["kind"] != kind:
211
+ continue
212
+ label = "caught" if kind == "attack" else "false alarm"
213
+ cells = " ".join(f"{report['layers'][l]['per_set'][name]:>10.1f}% {label[:6]:>6}" for l in layers)
214
+ lines.append(f"{name:{w}} {kind:7} {info['n']:>5} {cells}")
215
+ lines.append("")
216
+ for metric, label in (("detection_rate", "detection rate (avg of attack sets)"),
217
+ ("false_alarm_rate", "false-alarm rate (avg of benign sets)"),
218
+ ("ms_per_kb", "speed, ms per KB")):
219
+ lines.append(f"{label:{w + 15}}" + " ".join(f"{report['layers'][l][metric]:>18}" for l in layers))
220
+ return "\n".join(lines)
221
+
222
+
223
+ def main(json_out: str | None = None) -> int:
224
+ print("Fetching datasets (first run only)...")
225
+ samples = build(fetch())
226
+ print(f"{len(samples)} samples. Scoring...\n")
227
+ report = evaluate(samples)
228
+ print(render(report))
229
+ if json_out:
230
+ Path(json_out).write_text(json.dumps(report, indent=2))
231
+ return 0
232
+
233
+
234
+ # =============================================================================
235
+ # Agent replay: do the structural rules stop the harmful *action*?
236
+ # =============================================================================
237
+ # Detection asks whether text looks like an attack. What matters is whether the
238
+ # attack achieves anything. InjecAgent's 1,054 test cases describe a user task,
239
+ # a tool response carrying an injected instruction, and the attacker's tools
240
+ # (unlock a door, transfer money, email stolen data). We replay each one through
241
+ # a real Firewall as a fully hijacked agent would: read the injected response,
242
+ # then call the attacker's tools. A case counts as stopped if any harmful step
243
+ # is blocked or sent to the user for confirmation.
244
+ #
245
+ # The benign counterpart replays the same tools with a harmless response and
246
+ # values the user supplied, to measure how often legitimate actions get
247
+ # interrupted.
248
+
249
+ def _literal(text: str):
250
+ import ast
251
+ import warnings
252
+ try:
253
+ with warnings.catch_warnings():
254
+ warnings.simplefilter("ignore")
255
+ return ast.literal_eval(text)
256
+ except (ValueError, SyntaxError):
257
+ return {}
258
+
259
+
260
+ def agent_cases(paths: dict[str, Path]) -> list[dict]:
261
+ data = paths["InjecAgent"] / "data"
262
+ cases = []
263
+ for kind in ("dh", "ds"):
264
+ for c in json.loads((data / f"test_cases_{kind}_base.json").read_text()):
265
+ cases.append(dict(c, kind=kind))
266
+ sim = json.loads((data / "attacker_simulated_responses.json").read_text())
267
+ by_tool = {}
268
+ for key, value in sim.items():
269
+ tool, _, args = key[1:-1].partition(",")
270
+ by_tool.setdefault(tool, (_literal(args) if args.strip() != "{}" else {}, value))
271
+ for c in cases:
272
+ c["sim"] = by_tool
273
+ return cases
274
+
275
+
276
+ def _attacker_args(instruction: str) -> dict:
277
+ """Values the attacker chose (recipients, IDs, accounts, amounts) as tool arguments."""
278
+ from .flow import DESTINATIONS
279
+ args, seen = {}, set()
280
+ for kind, rx in DESTINATIONS:
281
+ for m in rx.finditer(instruction):
282
+ v = m.group(0).strip().rstrip(".,;")
283
+ if v not in seen:
284
+ seen.add(v)
285
+ args[f"{kind}_{len(args)}"] = v
286
+ return args
287
+
288
+
289
+ def replay(case: dict, policy_kwargs: dict, malicious: bool, rng: random.Random) -> bool:
290
+ """True if Naka stopped (or asked about) a harmful step."""
291
+ import tempfile
292
+
293
+ from .firewall import Firewall
294
+ from .flow import SessionState
295
+ from .policy import Policy
296
+ from .store import AuditLog, PinStore
297
+
298
+ tmp = Path(tempfile.mkdtemp(prefix="naka-replay-"))
299
+ session = SessionState("replay", shared=False)
300
+ pins = PinStore.__new__(PinStore)
301
+ pins.server, pins.path, pins.pins, pins.pending = "replay", tmp / "pins.json", {}, {}
302
+ pins._lock = __import__("threading").Lock()
303
+ fw = Firewall(Policy(**policy_kwargs), pins, AuditLog("replay", tmp / "audit.jsonl"), session=session)
304
+ rid = iter(range(1, 100))
305
+
306
+ def send(method, params):
307
+ i = next(rid)
308
+ return i, fw.from_client({"jsonrpc": "2.0", "id": i, "method": method, "params": params})
309
+
310
+ def answer(i, result):
311
+ return fw.from_server({"jsonrpc": "2.0", "id": i, "result": result})
312
+
313
+ names = [case["User Tool"]] + list(case["Attacker Tools"])
314
+ i, _ = send("tools/list", {})
315
+ answer(i, {"tools": [{"name": n, "description": n, "inputSchema": {"type": "object"}} for n in names]})
316
+
317
+ if malicious:
318
+ response = case["Tool Response"].strip().strip('"')
319
+ else:
320
+ response = case["Tool Response Template"].replace("<Attacker Instruction>", rng.choice(BENIGN_FILLS))
321
+ i, dec = send("tools/call", {"name": case["User Tool"], "arguments": _literal(case["Tool Parameters"]) or {}})
322
+ if not dec.forward:
323
+ return True
324
+ out = answer(i, {"content": [{"type": "text", "text": response}]})
325
+ if out.forward and out.forward[0].get("result", {}).get("isError"):
326
+ return True # withheld before the model saw it
327
+
328
+ stolen = ""
329
+ for tool in case["Attacker Tools"]:
330
+ if tool == "GmailSendEmail":
331
+ to = re.findall(r"[\w.+-]+@[\w-]+\.[\w.]+", case["Attacker Instruction"])
332
+ recipient = to[0] if (to and malicious) else "me@my-company.example"
333
+ args = {"to": recipient, "subject": "Info", "body": stolen or "See details."}
334
+ else:
335
+ sim_args, _ = case["sim"].get(tool, ({}, None))
336
+ args = dict(sim_args) if isinstance(sim_args, dict) else {}
337
+ if malicious:
338
+ args.update(_attacker_args(case["Attacker Instruction"]))
339
+ i, dec = send("tools/call", {"name": tool, "arguments": args})
340
+ if not dec.forward:
341
+ return True
342
+ _, sim_out = case["sim"].get(tool, ({}, "Done."))
343
+ result_text = sim_out if isinstance(sim_out, str) else json.dumps(sim_out)
344
+ answer(i, {"content": [{"type": "text", "text": result_text}]})
345
+ stolen = result_text
346
+ return False
347
+
348
+
349
+ import re # noqa: E402 (used by replay)
350
+
351
+ AGENT_CONFIGS = {
352
+ "detection only": {"on_untrusted_destination": "warn", "on_egress_after_injection": "warn",
353
+ "on_tainted_flow": "warn", "confirm_sensitive_after_untrusted": False},
354
+ "destinations only": {"confirm_sensitive_after_untrusted": False},
355
+ "default": {},
356
+ "default + classifier": {"classifier": "builtin"},
357
+ "strict": {"confirm_after_untrusted": True},
358
+ }
359
+
360
+
361
+ def agent_eval(paths: dict[str, Path], configs=AGENT_CONFIGS) -> dict:
362
+ cases = agent_cases(paths)
363
+ report = {"n": len(cases), "configs": {}}
364
+ for name, kwargs in configs.items():
365
+ rng = random.Random(3)
366
+ stopped = {"dh": [], "ds": []}
367
+ interrupted = []
368
+ for c in cases:
369
+ stopped[c["kind"]].append(replay(c, kwargs, True, rng))
370
+ interrupted.append(replay(c, kwargs, False, rng))
371
+ report["configs"][name] = {
372
+ "direct_harm_stopped": round(100 * sum(stopped["dh"]) / len(stopped["dh"]), 1),
373
+ "data_theft_stopped": round(100 * sum(stopped["ds"]) / len(stopped["ds"]), 1),
374
+ "benign_interrupted": round(100 * sum(interrupted) / len(interrupted), 1),
375
+ }
376
+ return report
377
+
378
+
379
+ def render_agent(report: dict) -> str:
380
+ lines = [f"InjecAgent replay: {report['n']} attack cases (hijacked agent), same number of benign runs",
381
+ f"{'configuration':24} {'direct harm stopped':>20} {'data theft stopped':>20} {'benign interrupted':>20}"]
382
+ for name, r in report["configs"].items():
383
+ lines.append(f"{name:24} {r['direct_harm_stopped']:>19}% {r['data_theft_stopped']:>19}% "
384
+ f"{r['benign_interrupted']:>19}%")
385
+ lines.append("stopped = blocked, or held for the user's confirmation")
386
+ return "\n".join(lines)
naka/classifier.py ADDED
@@ -0,0 +1,134 @@
1
+ """A small built-in machine-learning classifier for injected instructions.
2
+
3
+ Why it exists: the regex rules in detectors.py catch known phrasings and miss
4
+ rewordings and other languages. A learned model generalises from tens of
5
+ thousands of labelled examples instead of a hand-written list.
6
+
7
+ Why this design:
8
+ - Zero dependencies at runtime. It is a logistic regression over hashed word
9
+ n-grams (1 to 3 words), trained offline by scripts/train_classifier.py.
10
+ Scoring is a few hash lookups per word, in pure Python.
11
+ - Windows, not whole documents. An injection is usually a sentence or two
12
+ inside a long, harmless tool result. Averaging over the whole text would
13
+ dilute it, so we slide a window over the text and keep the worst score.
14
+ - Same normalisation as the regex layer, so invisible characters, look-alike
15
+ letters and fullwidth text are undone before scoring.
16
+
17
+ It is a second opinion, not a replacement: regex is fast and explainable,
18
+ this catches what regex misses, and the structural rules (pinning, data flow)
19
+ don't depend on either.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import gzip
25
+ import json
26
+ import math
27
+ import re
28
+ import struct
29
+ import zlib
30
+ from array import array
31
+ from dataclasses import dataclass
32
+ from importlib import resources
33
+
34
+ from . import detectors as d
35
+
36
+ # Words, plus a few symbols that mark fake system tags ("<", "#", "!").
37
+ # JSON punctuation is left out so structured data doesn't look suspicious.
38
+ TOKEN = re.compile(r"[a-z0-9\u0900-\u097f\u00c0-\u024f\u0400-\u04ff]+|[<>#!?]")
39
+ DEFAULT_MODEL = "injection-v1.bin.gz"
40
+
41
+
42
+ def tokens(text: str) -> list[str]:
43
+ text = d.normalize(d.strip_hidden(text)).lower()
44
+ return TOKEN.findall(text)
45
+
46
+
47
+ def features(toks: list[str], n_max: int, buckets: int) -> list[int]:
48
+ """Hashed word n-grams. crc32 is used because Python's hash() is randomised."""
49
+ out = []
50
+ for n in range(1, n_max + 1):
51
+ for i in range(len(toks) - n + 1):
52
+ gram = "\x1f".join(toks[i:i + n]).encode("utf-8")
53
+ out.append(zlib.crc32(gram) % buckets)
54
+ return out
55
+
56
+
57
+ @dataclass
58
+ class Score:
59
+ probability: float
60
+ excerpt: str
61
+
62
+
63
+ class Classifier:
64
+ def __init__(self, weights: array, bias: float, meta: dict):
65
+ self.weights = weights
66
+ self.bias = bias
67
+ self.meta = meta
68
+ self.buckets = meta["buckets"]
69
+ self.n_max = meta["ngram_max"]
70
+ self.window = meta["window"]
71
+ self.stride = meta["stride"]
72
+ self.threshold = meta["threshold"]
73
+
74
+ # ------------------------------------------------------------ persistence
75
+ @classmethod
76
+ def load(cls, path: str | None = None) -> "Classifier":
77
+ if path:
78
+ blob = open(path, "rb").read()
79
+ else:
80
+ blob = resources.files("naka.models").joinpath(DEFAULT_MODEL).read_bytes()
81
+ raw = gzip.decompress(blob)
82
+ (header_len,) = struct.unpack("<I", raw[:4])
83
+ meta = json.loads(raw[4:4 + header_len])
84
+ body = raw[4 + header_len:]
85
+ count = meta["nonzero"]
86
+ idx = array("I")
87
+ idx.frombytes(body[: 4 * count])
88
+ val = array("f")
89
+ val.frombytes(body[4 * count: 8 * count])
90
+ weights = array("f", bytes(4 * meta["buckets"]))
91
+ for i, w in zip(idx, val):
92
+ weights[i] = w
93
+ return cls(weights, meta["bias"], meta)
94
+
95
+ @staticmethod
96
+ def save(path: str, nonzero: list[tuple[int, float]], bias: float, meta: dict) -> None:
97
+ meta = dict(meta, bias=bias, nonzero=len(nonzero))
98
+ header = json.dumps(meta, sort_keys=True).encode()
99
+ idx = array("I", [i for i, _ in nonzero])
100
+ val = array("f", [w for _, w in nonzero])
101
+ raw = struct.pack("<I", len(header)) + header + idx.tobytes() + val.tobytes()
102
+ with open(path, "wb") as fh:
103
+ fh.write(gzip.compress(raw, 9))
104
+
105
+ # ---------------------------------------------------------------- scoring
106
+ def _prob(self, toks: list[str]) -> float:
107
+ z = self.bias + sum(self.weights[f] for f in features(toks, self.n_max, self.buckets))
108
+ if z < -40:
109
+ return 0.0
110
+ return 1.0 / (1.0 + math.exp(-z))
111
+
112
+ def score(self, text: str) -> Score:
113
+ """Worst (highest) injection probability over sliding windows of the text."""
114
+ toks = tokens(text)
115
+ if not toks:
116
+ return Score(0.0, "")
117
+ if len(toks) <= self.window:
118
+ return Score(self._prob(toks), " ".join(toks)[:120])
119
+ best, best_at = 0.0, 0
120
+ for start in range(0, len(toks) - self.stride, self.stride):
121
+ p = self._prob(toks[start:start + self.window])
122
+ if p > best:
123
+ best, best_at = p, start
124
+ return Score(best, " ".join(toks[best_at:best_at + self.window])[:120])
125
+
126
+
127
+ _default: Classifier | None = None
128
+
129
+
130
+ def default() -> Classifier:
131
+ global _default
132
+ if _default is None:
133
+ _default = Classifier.load()
134
+ return _default