Skip to content

Commit fcc7a9a

Browse files
committed
feat(0.5.9): airlock semantic-report; document pip-audit exceptions
Adds the observe-window review tool. With the local operational corpus declined, observe-mode output is the only false-positive evidence drawn from real traffic -- and without a way to read it, observe mode produces data nobody looks at. `airlock semantic-report [--days N] [--json]` aggregates verdicts from the request JSONL via the bounded reader: detections and clean/unavailable counts per classifier, tripwire category histogram, provider confidence distribution, short-circuit counts, and unavailability broken down by reason. It keeps `status` (the classifier verdict) separate from `action` (what Airlock did), so an observed detection is never miscounted as a block, and it flags `rate_limit` specifically as the attacker-inducible cause. Detection samples carry request IDs and never prompt text. A truncated window is disclosed rather than silently producing partial totals. Run against real logs it immediately surfaced a true false positive: a security-review request was flagged by the tripwire on quoted attack prose, which is the quoted-benign class the benchmarks predicted. Also unblocks CI. pip-audit exits non-zero on three cryptography 48.0.1 advisories that cannot be fixed here: litellm[proxy] and presidio-anonymizer both pin cryptography<49, while the fixes land in 49 and 50. All three require PKCS#7 decryption or X.509 path validation; Airlock imports cryptography nowhere directly, does neither, and signs capability tokens with HS256 (HMAC). The suppressions are scoped to those three IDs and documented in dev/notes/security-pip-audit-exceptions.md with the removal trigger -- when both upstreams allow cryptography>=50. Local dry run of every CI job passes: uv lock --check, ruff check, ruff format --check, mypy fast subsystem, documentation contract, strict mkdocs build, full non-live suite (3009 passed), pip-audit, and docker build.
1 parent e8762b7 commit fcc7a9a

6 files changed

Lines changed: 741 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,20 @@ jobs:
125125
uses: astral-sh/setup-uv@v8.0.0
126126

127127
- name: Install and audit
128+
# Suppressions are documented, scoped, and reviewable in
129+
# dev/notes/security-pip-audit-exceptions.md. Each records why it cannot
130+
# be fixed, why it is unreachable in Airlock's usage, and the condition
131+
# for removal. Do not add one without that assessment.
132+
#
133+
# cryptography 48.0.1: litellm[proxy] and presidio-anonymizer both pin
134+
# <49, blocking the 49/50 fixes. All three findings need PKCS#7
135+
# decryption or X.509 path validation; Airlock does neither and imports
136+
# cryptography nowhere directly.
128137
run: |
129138
uv sync --locked --all-extras
130139
. scripts/tool-versions.sh
131140
uv pip install "pip-audit==$AIRLOCK_PIP_AUDIT_VERSION"
132-
uv run pip-audit
141+
uv run pip-audit \
142+
--ignore-vuln PYSEC-2026-3552 \
143+
--ignore-vuln PYSEC-2026-3553 \
144+
--ignore-vuln PYSEC-2026-3554

airlock/cli/main.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,30 @@ def main(argv: list[str] | None = None) -> None:
175175
help="Write report to file instead of stdout.",
176176
)
177177

178+
# -- semantic-report --
179+
semantic_parser = subparsers.add_parser(
180+
"semantic-report",
181+
help="Summarize semantic classifier verdicts from the request logs.",
182+
)
183+
semantic_parser.add_argument(
184+
"--days", type=int, default=7, help="Days of logs to summarize (default: 7)."
185+
)
186+
semantic_parser.add_argument(
187+
"--json",
188+
action="store_true",
189+
dest="semantic_json",
190+
help="Output raw JSON instead of formatted text.",
191+
)
192+
semantic_parser.add_argument(
193+
"--samples",
194+
type=int,
195+
default=25,
196+
help="Max detection samples to list (identifiers only, no prompt text).",
197+
)
198+
semantic_parser.add_argument(
199+
"--output", "-o", default=None, help="Write the report to a file."
200+
)
201+
178202
# -- hooks --
179203
hooks_parser = subparsers.add_parser(
180204
"hooks",
@@ -481,6 +505,24 @@ def main(argv: list[str] | None = None) -> None:
481505

482506
analyze_main()
483507

508+
elif args.command == "semantic-report":
509+
import json as _json
510+
511+
from airlock.semantic_report import build_report, render_text
512+
513+
report = build_report(days=args.days, max_samples=args.samples)
514+
rendered = (
515+
_json.dumps(report.as_dict(), indent=2, default=str)
516+
if args.semantic_json
517+
else render_text(report)
518+
)
519+
if args.output:
520+
Path(args.output).write_text(rendered + "\n", encoding="utf-8")
521+
print(f"Wrote {args.output}")
522+
else:
523+
print(rendered)
524+
raise SystemExit(0)
525+
484526
elif args.command == "hooks":
485527
from airlock.cli.hooks_cmd import run_install, run_status
486528

airlock/semantic_report.py

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
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

Comments
 (0)