|
| 1 | +"""Aggregate semantic-classifier verdicts out of the request JSONL. |
| 2 | +
|
| 3 | +This is the review tool for an **observe window**. With the local operational |
| 4 | +corpus declined and semantic providers optionally disabled, observe-mode output |
| 5 | +is the only source of false-positive evidence drawn from real traffic — and |
| 6 | +without a way to read it, running in observe mode produces data nobody looks at. |
| 7 | +
|
| 8 | +What it answers, in priority order: |
| 9 | +
|
| 10 | +**What did the classifiers flag, and would any of it have blocked real work?** |
| 11 | +Detections are grouped by classifier and by tripwire category so an operator can |
| 12 | +see whether the flags cluster on a pattern that matches ordinary traffic. |
| 13 | +
|
| 14 | +**How often was the classifier unable to answer?** Unavailability fails open by |
| 15 | +default, so a provider outage looks like quiet, healthy traffic. The |
| 16 | +`unavailable` breakdown by reason is the alerting signal — in particular |
| 17 | +`rate_limit`, which is the one an attacker can induce deliberately. |
| 18 | +
|
| 19 | +**What did Airlock actually do?** `status` is the classifier verdict; `action` |
| 20 | +is the outcome. They differ in every mode except enforce, and the report keeps |
| 21 | +them separate so an observed detection is never miscounted as a block. |
| 22 | +
|
| 23 | +The report contains **no prompt text** — classifiers never record it, and this |
| 24 | +tool only reads what they wrote. Request IDs are included so a specific case can |
| 25 | +be looked up deliberately, rather than by pasting content into a report. |
| 26 | +""" |
| 27 | + |
| 28 | +from __future__ import annotations |
| 29 | + |
| 30 | +from collections import Counter |
| 31 | +from dataclasses import dataclass, field |
| 32 | +from typing import Any |
| 33 | + |
| 34 | +from airlock.log_query import LogPage, LogQuery, query_logs |
| 35 | + |
| 36 | +#: Verdict-bearing field written by the semantic guard. |
| 37 | +SEMANTIC_KEY = "airlock_semantic" |
| 38 | + |
| 39 | + |
| 40 | +def _semantic(record: dict[str, Any]) -> dict[str, Any] | None: |
| 41 | + """Return the semantic block, whether top-level or under metadata.""" |
| 42 | + value = record.get(SEMANTIC_KEY) |
| 43 | + if isinstance(value, dict): |
| 44 | + return value |
| 45 | + metadata = record.get("metadata") |
| 46 | + if isinstance(metadata, dict): |
| 47 | + nested = metadata.get(SEMANTIC_KEY) |
| 48 | + if isinstance(nested, dict): |
| 49 | + return nested |
| 50 | + return None |
| 51 | + |
| 52 | + |
| 53 | +def has_semantic_verdict(record: dict[str, Any]) -> bool: |
| 54 | + return _semantic(record) is not None |
| 55 | + |
| 56 | + |
| 57 | +@dataclass |
| 58 | +class ClassifierSummary: |
| 59 | + name: str |
| 60 | + runs: int = 0 |
| 61 | + detections: int = 0 |
| 62 | + clean: int = 0 |
| 63 | + unavailable: int = 0 |
| 64 | + errors: Counter = field(default_factory=Counter) |
| 65 | + categories: Counter = field(default_factory=Counter) |
| 66 | + confidence: Counter = field(default_factory=Counter) |
| 67 | + latency_ms_total: float = 0.0 |
| 68 | + |
| 69 | + @property |
| 70 | + def detection_rate(self) -> float: |
| 71 | + answered = self.detections + self.clean |
| 72 | + return round(self.detections / answered, 4) if answered else 0.0 |
| 73 | + |
| 74 | + @property |
| 75 | + def unavailable_rate(self) -> float: |
| 76 | + return round(self.unavailable / self.runs, 4) if self.runs else 0.0 |
| 77 | + |
| 78 | + @property |
| 79 | + def mean_latency_ms(self) -> float: |
| 80 | + return round(self.latency_ms_total / self.runs, 2) if self.runs else 0.0 |
| 81 | + |
| 82 | + def as_dict(self) -> dict[str, Any]: |
| 83 | + return { |
| 84 | + "name": self.name, |
| 85 | + "runs": self.runs, |
| 86 | + "detections": self.detections, |
| 87 | + "clean": self.clean, |
| 88 | + "unavailable": self.unavailable, |
| 89 | + "detection_rate": self.detection_rate, |
| 90 | + "unavailable_rate": self.unavailable_rate, |
| 91 | + "mean_latency_ms": self.mean_latency_ms, |
| 92 | + "categories": dict(self.categories.most_common()), |
| 93 | + "confidence": dict(self.confidence.most_common()), |
| 94 | + "errors": dict(self.errors.most_common()), |
| 95 | + } |
| 96 | + |
| 97 | + |
| 98 | +@dataclass |
| 99 | +class SemanticReport: |
| 100 | + days: int |
| 101 | + requests_with_verdicts: int = 0 |
| 102 | + modes: Counter = field(default_factory=Counter) |
| 103 | + actions: Counter = field(default_factory=Counter) |
| 104 | + statuses: Counter = field(default_factory=Counter) |
| 105 | + input_kinds: Counter = field(default_factory=Counter) |
| 106 | + selection: Counter = field(default_factory=Counter) |
| 107 | + unavailable_reasons: Counter = field(default_factory=Counter) |
| 108 | + short_circuited: Counter = field(default_factory=Counter) |
| 109 | + classifiers: dict[str, ClassifierSummary] = field(default_factory=dict) |
| 110 | + detection_samples: list[dict[str, Any]] = field(default_factory=list) |
| 111 | + window: dict[str, Any] = field(default_factory=dict) |
| 112 | + |
| 113 | + @property |
| 114 | + def detections(self) -> int: |
| 115 | + return self.statuses.get("blocked", 0) |
| 116 | + |
| 117 | + @property |
| 118 | + def blocked_requests(self) -> int: |
| 119 | + """Requests actually rejected — `action`, never `status`.""" |
| 120 | + return self.actions.get("blocked", 0) |
| 121 | + |
| 122 | + def as_dict(self) -> dict[str, Any]: |
| 123 | + return { |
| 124 | + "days": self.days, |
| 125 | + "requests_with_verdicts": self.requests_with_verdicts, |
| 126 | + "detections": self.detections, |
| 127 | + "blocked_requests": self.blocked_requests, |
| 128 | + "modes": dict(self.modes), |
| 129 | + "actions": dict(self.actions), |
| 130 | + "statuses": dict(self.statuses), |
| 131 | + "input_kinds": dict(self.input_kinds), |
| 132 | + "selection": dict(self.selection), |
| 133 | + "unavailable_reasons": dict(self.unavailable_reasons.most_common()), |
| 134 | + "short_circuited": dict(self.short_circuited.most_common()), |
| 135 | + "classifiers": [c.as_dict() for c in self.classifiers.values()], |
| 136 | + "detection_samples": self.detection_samples, |
| 137 | + "window": self.window, |
| 138 | + } |
| 139 | + |
| 140 | + |
| 141 | +def build_report( |
| 142 | + days: int = 7, |
| 143 | + *, |
| 144 | + directory: Any = None, |
| 145 | + max_samples: int = 25, |
| 146 | + page: LogPage | None = None, |
| 147 | +) -> SemanticReport: |
| 148 | + """Aggregate semantic verdicts over the requested window.""" |
| 149 | + if page is None: |
| 150 | + page = query_logs( |
| 151 | + LogQuery(days=days, predicate=has_semantic_verdict, directory=directory) |
| 152 | + ) |
| 153 | + |
| 154 | + report = SemanticReport(days=days) |
| 155 | + report.window = page.as_metadata() |
| 156 | + |
| 157 | + for record in page.records: |
| 158 | + semantic = _semantic(record) |
| 159 | + if semantic is None: |
| 160 | + continue |
| 161 | + report.requests_with_verdicts += 1 |
| 162 | + report.modes[str(semantic.get("mode", "unknown"))] += 1 |
| 163 | + report.actions[str(semantic.get("action", "unknown"))] += 1 |
| 164 | + report.statuses[str(semantic.get("status", "unknown"))] += 1 |
| 165 | + report.input_kinds[str(semantic.get("input_kind", "unknown"))] += 1 |
| 166 | + report.selection[str(semantic.get("selection", "unknown"))] += 1 |
| 167 | + for entry in semantic.get("short_circuited") or []: |
| 168 | + if isinstance(entry, dict) and entry.get("name"): |
| 169 | + report.short_circuited[str(entry["name"])] += 1 |
| 170 | + |
| 171 | + for result in semantic.get("results") or []: |
| 172 | + if not isinstance(result, dict): |
| 173 | + continue |
| 174 | + name = str(result.get("name", "unknown")) |
| 175 | + summary = report.classifiers.setdefault(name, ClassifierSummary(name)) |
| 176 | + summary.runs += 1 |
| 177 | + summary.latency_ms_total += float(result.get("duration_ms") or 0.0) |
| 178 | + |
| 179 | + label = str(result.get("label", "")) |
| 180 | + if label == "unavailable" or result.get("error"): |
| 181 | + summary.unavailable += 1 |
| 182 | + error = str(result.get("error") or "unknown") |
| 183 | + summary.errors[error] += 1 |
| 184 | + meta = result.get("metadata") |
| 185 | + reason = ( |
| 186 | + meta.get("unavailable_reason") if isinstance(meta, dict) else None |
| 187 | + ) |
| 188 | + report.unavailable_reasons[str(reason or error)] += 1 |
| 189 | + elif result.get("blocked"): |
| 190 | + summary.detections += 1 |
| 191 | + else: |
| 192 | + summary.clean += 1 |
| 193 | + |
| 194 | + meta = result.get("metadata") |
| 195 | + if isinstance(meta, dict): |
| 196 | + for category in meta.get("categories") or []: |
| 197 | + summary.categories[str(category)] += 1 |
| 198 | + for provider in meta.get("provider_results") or []: |
| 199 | + if isinstance(provider, dict) and provider.get("confidence"): |
| 200 | + summary.confidence[str(provider["confidence"])] += 1 |
| 201 | + |
| 202 | + if ( |
| 203 | + semantic.get("status") == "blocked" |
| 204 | + and len(report.detection_samples) < max_samples |
| 205 | + ): |
| 206 | + # Identifiers only — never prompt text. A reviewer looks the request |
| 207 | + # up deliberately rather than reading content out of a report. |
| 208 | + report.detection_samples.append( |
| 209 | + { |
| 210 | + "timestamp": record.get("timestamp"), |
| 211 | + "request_id": record.get("request_id"), |
| 212 | + "client": record.get("airlock_client"), |
| 213 | + "model": record.get("model"), |
| 214 | + "action": semantic.get("action"), |
| 215 | + "blocking_classifier": semantic.get("blocking_classifier"), |
| 216 | + "input_kind": semantic.get("input_kind"), |
| 217 | + } |
| 218 | + ) |
| 219 | + |
| 220 | + return report |
| 221 | + |
| 222 | + |
| 223 | +def render_text(report: SemanticReport) -> str: |
| 224 | + """Human-readable rendering for the CLI.""" |
| 225 | + lines: list[str] = [] |
| 226 | + add = lines.append |
| 227 | + |
| 228 | + add("Semantic classifier report") |
| 229 | + add("=" * 60) |
| 230 | + add(f"Window: last {report.days} day(s)") |
| 231 | + add(f"Requests with verdicts: {report.requests_with_verdicts:,}") |
| 232 | + |
| 233 | + if report.window.get("truncated"): |
| 234 | + add( |
| 235 | + f" ! window TRUNCATED ({report.window.get('limit_hit')}) — " |
| 236 | + "these totals cover only part of the requested period" |
| 237 | + ) |
| 238 | + if not report.requests_with_verdicts: |
| 239 | + add("") |
| 240 | + add("No semantic verdicts found. Either no classifier is registered, or") |
| 241 | + add("the guard has not run over this window.") |
| 242 | + return "\n".join(lines) |
| 243 | + |
| 244 | + add(f"Detections (verdict): {report.detections:,}") |
| 245 | + add(f"Blocked (action): {report.blocked_requests:,}") |
| 246 | + if report.detections and not report.blocked_requests: |
| 247 | + add(" (observe/shadow mode — nothing was actually rejected)") |
| 248 | + |
| 249 | + add("") |
| 250 | + add(f"Modes: {dict(report.modes)}") |
| 251 | + add(f"Actions: {dict(report.actions)}") |
| 252 | + add(f"Input kinds: {dict(report.input_kinds)}") |
| 253 | + if report.short_circuited: |
| 254 | + add(f"Short-circuited by light tier: {dict(report.short_circuited)}") |
| 255 | + |
| 256 | + if report.unavailable_reasons: |
| 257 | + add("") |
| 258 | + add("Unavailable verdicts by reason (fails open — watch this):") |
| 259 | + for reason, count in report.unavailable_reasons.most_common(): |
| 260 | + marker = " <-- attacker-inducible" if reason == "rate_limit" else "" |
| 261 | + add(f" {count:6,} {reason}{marker}") |
| 262 | + |
| 263 | + add("") |
| 264 | + add("Per classifier:") |
| 265 | + for summary in report.classifiers.values(): |
| 266 | + add(f" {summary.name}") |
| 267 | + add( |
| 268 | + f" runs={summary.runs:,} detections={summary.detections:,} " |
| 269 | + f"clean={summary.clean:,} unavailable={summary.unavailable:,}" |
| 270 | + ) |
| 271 | + add( |
| 272 | + f" detection_rate={summary.detection_rate:.4f} " |
| 273 | + f"unavailable_rate={summary.unavailable_rate:.4f} " |
| 274 | + f"mean_latency={summary.mean_latency_ms}ms" |
| 275 | + ) |
| 276 | + if summary.categories: |
| 277 | + add(f" categories: {dict(summary.categories.most_common(8))}") |
| 278 | + if summary.confidence: |
| 279 | + add(f" confidence: {dict(summary.confidence)}") |
| 280 | + if summary.errors: |
| 281 | + add(f" errors: {dict(summary.errors.most_common(5))}") |
| 282 | + |
| 283 | + if report.detection_samples: |
| 284 | + add("") |
| 285 | + add( |
| 286 | + f"Detection samples ({len(report.detection_samples)} shown, no prompt text):" |
| 287 | + ) |
| 288 | + for sample in report.detection_samples: |
| 289 | + add( |
| 290 | + f" {sample.get('timestamp', '')[:19]} " |
| 291 | + f"{str(sample.get('client')):16s} {str(sample.get('model')):22s} " |
| 292 | + f"{sample.get('blocking_classifier')} -> {sample.get('action')}" |
| 293 | + ) |
| 294 | + add("") |
| 295 | + add("Review these by request_id. A detection on ordinary work is a false") |
| 296 | + add("positive and is the evidence that should gate promoting the mode.") |
| 297 | + |
| 298 | + return "\n".join(lines) |
0 commit comments