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 +3 -0
- naka/__main__.py +5 -0
- naka/bench.py +386 -0
- naka/classifier.py +134 -0
- naka/cli.py +249 -0
- naka/data/__init__.py +1 -0
- naka/data/redteam_corpus.json +67 -0
- naka/demo/__init__.py +0 -0
- naka/demo/evil_server.py +146 -0
- naka/demo/run_demo.py +234 -0
- naka/demo/workspace_server.py +78 -0
- naka/detectors.py +393 -0
- naka/evals/__init__.py +0 -0
- naka/evals/cli.py +95 -0
- naka/evals/core.py +408 -0
- naka/evals/server.py +94 -0
- naka/evals/suites.py +522 -0
- naka/firewall.py +1076 -0
- naka/flow.py +382 -0
- naka/install.py +226 -0
- naka/models/MODEL_CARD.md +34 -0
- naka/models/__init__.py +1 -0
- naka/models/injection-v1.bin.gz +0 -0
- naka/policy.py +105 -0
- naka/proxy.py +153 -0
- naka/sandbox.py +183 -0
- naka/store.py +126 -0
- naka/upstream.py +240 -0
- naka-0.3.0.dist-info/METADATA +242 -0
- naka-0.3.0.dist-info/RECORD +34 -0
- naka-0.3.0.dist-info/WHEEL +5 -0
- naka-0.3.0.dist-info/entry_points.txt +2 -0
- naka-0.3.0.dist-info/licenses/LICENSE +179 -0
- naka-0.3.0.dist-info/top_level.txt +1 -0
naka/__init__.py
ADDED
naka/__main__.py
ADDED
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
|