contextwall 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.
Files changed (66) hide show
  1. context_firewall/__init__.py +3 -0
  2. context_firewall/analytics/__init__.py +5 -0
  3. context_firewall/analytics/engine.py +234 -0
  4. context_firewall/api/__init__.py +5 -0
  5. context_firewall/api/app.py +1020 -0
  6. context_firewall/api/models.py +54 -0
  7. context_firewall/classifier/__init__.py +5 -0
  8. context_firewall/classifier/classifier.py +165 -0
  9. context_firewall/cli/__init__.py +5 -0
  10. context_firewall/cli/main.py +513 -0
  11. context_firewall/compliance/__init__.py +1 -0
  12. context_firewall/compliance/baa.py +57 -0
  13. context_firewall/compliance/chain.py +84 -0
  14. context_firewall/compliance/control_mappings.py +92 -0
  15. context_firewall/compliance/export.py +280 -0
  16. context_firewall/compliance/keys.py +94 -0
  17. context_firewall/config.py +234 -0
  18. context_firewall/control_plane/__init__.py +0 -0
  19. context_firewall/control_plane/client.py +107 -0
  20. context_firewall/control_plane/models.py +72 -0
  21. context_firewall/control_plane/pusher.py +377 -0
  22. context_firewall/daemon/__init__.py +5 -0
  23. context_firewall/daemon/main.py +461 -0
  24. context_firewall/db/__init__.py +6 -0
  25. context_firewall/db/connection.py +30 -0
  26. context_firewall/db/migrations.py +408 -0
  27. context_firewall/entropy/__init__.py +5 -0
  28. context_firewall/entropy/engine.py +271 -0
  29. context_firewall/graph/__init__.py +5 -0
  30. context_firewall/graph/engine.py +162 -0
  31. context_firewall/lint/__init__.py +0 -0
  32. context_firewall/lint/engine.py +334 -0
  33. context_firewall/mcp/__init__.py +5 -0
  34. context_firewall/mcp/server.py +237 -0
  35. context_firewall/metrics.py +100 -0
  36. context_firewall/models.py +130 -0
  37. context_firewall/policy/__init__.py +10 -0
  38. context_firewall/policy/detectors/__init__.py +0 -0
  39. context_firewall/policy/detectors/injection.py +480 -0
  40. context_firewall/policy/dsl/__init__.py +0 -0
  41. context_firewall/policy/dsl/evaluator.py +208 -0
  42. context_firewall/policy/dsl/loader.py +150 -0
  43. context_firewall/policy/dsl/types.py +112 -0
  44. context_firewall/policy/engine.py +532 -0
  45. context_firewall/provenance/__init__.py +5 -0
  46. context_firewall/provenance/engine.py +658 -0
  47. context_firewall/provenance/models.py +91 -0
  48. context_firewall/proxy/__init__.py +1 -0
  49. context_firewall/proxy/router.py +519 -0
  50. context_firewall/proxy/scanner.py +214 -0
  51. context_firewall/proxy/tokens.py +156 -0
  52. context_firewall/runtime/__init__.py +5 -0
  53. context_firewall/runtime/engine.py +303 -0
  54. context_firewall/source/__init__.py +1 -0
  55. context_firewall/source/registry.py +299 -0
  56. context_firewall/source/types.py +12 -0
  57. context_firewall/synthesizer/__init__.py +5 -0
  58. context_firewall/synthesizer/synthesizer.py +255 -0
  59. context_firewall/trust/__init__.py +5 -0
  60. context_firewall/trust/engine.py +139 -0
  61. context_firewall/trust/signals.py +184 -0
  62. contextwall-0.1.0.dist-info/METADATA +431 -0
  63. contextwall-0.1.0.dist-info/RECORD +66 -0
  64. contextwall-0.1.0.dist-info/WHEEL +4 -0
  65. contextwall-0.1.0.dist-info/entry_points.txt +3 -0
  66. contextwall-0.1.0.dist-info/licenses/LICENSE +33 -0
@@ -0,0 +1,1020 @@
1
+ """FastAPI application factory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import time
9
+ import uuid
10
+ from datetime import datetime, timezone
11
+ from typing import Any
12
+
13
+ from fastapi import Depends, FastAPI, Header, HTTPException, Request, WebSocket, WebSocketDisconnect
14
+ from fastapi.middleware.cors import CORSMiddleware
15
+ from fastapi.responses import Response, StreamingResponse
16
+
17
+ from context_firewall.api.models import (
18
+ AnalyzeRequest,
19
+ APIToken,
20
+ BundleRequest,
21
+ ErrorResponse,
22
+ OutcomeRequest,
23
+ )
24
+ from context_firewall.config import Config
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ def _build_token_store(config: Config) -> dict[str, APIToken]:
30
+ store: dict[str, APIToken] = {}
31
+ for entry in config.rest_api.auth.tokens:
32
+ token_val = entry.get("token", "")
33
+ if token_val:
34
+ store[token_val] = APIToken(
35
+ token=token_val,
36
+ name=entry.get("name", "unnamed"),
37
+ scopes=entry.get("scopes", ["analyze", "bundle"]),
38
+ )
39
+ return store
40
+
41
+
42
+ def create_app(
43
+ config: Config,
44
+ engines: dict[str, Any] | None = None,
45
+ ) -> FastAPI:
46
+ app = FastAPI(title="ContextWall API", version="0.1.0")
47
+ app.add_middleware(
48
+ CORSMiddleware,
49
+ allow_origins=["*"],
50
+ allow_credentials=True,
51
+ allow_methods=["*"],
52
+ allow_headers=["*"],
53
+ )
54
+ _engines = engines or {}
55
+ token_store = _build_token_store(config)
56
+ auth_enabled = config.rest_api.auth.enabled
57
+
58
+ # ── Auth ──────────────────────────────────────────────────────────────────
59
+
60
+ async def require_auth(authorization: str = Header(alias="Authorization", default="")) -> APIToken:
61
+ if not auth_enabled:
62
+ return APIToken(token="", name="anonymous", scopes=["analyze", "bundle"])
63
+ bearer = authorization.removeprefix("Bearer ").strip()
64
+ api_token = token_store.get(bearer)
65
+ if not api_token:
66
+ raise HTTPException(
67
+ status_code=401,
68
+ detail={"error": "invalid token", "code": "UNAUTHORIZED"},
69
+ )
70
+ return api_token
71
+
72
+ def require_scope(scope: str):
73
+ async def check(token: APIToken = Depends(require_auth)) -> APIToken:
74
+ if scope not in token.scopes:
75
+ raise HTTPException(
76
+ status_code=403,
77
+ detail={"error": "insufficient scope", "code": "FORBIDDEN"},
78
+ )
79
+ return token
80
+ return check
81
+
82
+ # ── Middleware ────────────────────────────────────────────────────────────
83
+
84
+ @app.middleware("http")
85
+ async def logging_middleware(request: Request, call_next):
86
+ req_id = str(uuid.uuid4())[:8]
87
+ request.state.request_id = req_id
88
+ response = await call_next(request)
89
+ logger.info(
90
+ "http",
91
+ extra={
92
+ "method": request.method,
93
+ "path": request.url.path,
94
+ "status": response.status_code,
95
+ "request_id": req_id,
96
+ },
97
+ )
98
+ return response
99
+
100
+ # ── Pipeline helper ───────────────────────────────────────────────────────
101
+
102
+ async def _run_pipeline(task: str, session_id: str, repository_root: str | None, request: Request):
103
+ from context_firewall import metrics as m
104
+
105
+ classifier = _engines.get("classifier")
106
+ graph = _engines.get("graph")
107
+ trust = _engines.get("trust")
108
+ policy = _engines.get("policy")
109
+ synthesizer = _engines.get("synthesizer")
110
+ provenance = _engines.get("provenance")
111
+
112
+ req_id = getattr(request.state, "request_id", str(uuid.uuid4())[:8])
113
+
114
+ if not classifier or not graph or not synthesizer:
115
+ m.ERRORS.labels(component="pipeline").inc()
116
+ raise HTTPException(status_code=503, detail={"error": "engines not ready", "code": "SERVICE_UNAVAILABLE"})
117
+
118
+ # Auto-create session on first use (INSERT OR IGNORE is safe for repeat calls)
119
+ if provenance:
120
+ from context_firewall.provenance.models import Session
121
+ await provenance.create_session(Session(
122
+ session_id=session_id,
123
+ agent_id="api",
124
+ model_version="",
125
+ repository_root=repository_root or config.repository_root or "",
126
+ client_type="rest",
127
+ status="open",
128
+ started_at=datetime.now(timezone.utc),
129
+ ))
130
+
131
+ from context_firewall.classifier.classifier import build_pipeline_context
132
+ classification = classifier.classify_task(task)
133
+ ctx = build_pipeline_context(task, classification, session_id, config)
134
+ task_type = classification.task_type.value
135
+
136
+ # Emit context_request event so live feed shows incoming pipeline requests
137
+ if provenance:
138
+ from context_firewall.provenance.models import PolicyEnforcementEvent
139
+ await provenance.emit_policy_enforcement(PolicyEnforcementEvent(
140
+ request_id=ctx.request_id,
141
+ session_id=session_id,
142
+ rule_name="context_request",
143
+ action="audit-only",
144
+ file_path="",
145
+ reason=f"task={task_type} session={session_id}",
146
+ occurred_at=datetime.now(timezone.utc),
147
+ ))
148
+
149
+ t0 = time.perf_counter()
150
+ try:
151
+ candidates = await graph.get_candidates(ctx, repository_root)
152
+
153
+ if trust:
154
+ candidates = await trust.score_candidates(candidates, ctx)
155
+
156
+ violations = 0
157
+ if policy:
158
+ candidates, violations = await policy.evaluate(candidates, ctx.request_id, session_id)
159
+ if violations:
160
+ m.POLICY_ENFORCEMENTS.labels(action="deny", rule_name="pipeline").inc(violations)
161
+
162
+ bundle = await synthesizer.assemble(candidates, ctx, violations)
163
+
164
+ elapsed = time.perf_counter() - t0
165
+ m.PIPELINE_REQUESTS.labels(task_type=task_type, status="ok").inc()
166
+ m.PIPELINE_DURATION.labels(task_type=task_type).observe(elapsed)
167
+ m.PIPELINE_SLICES.labels(task_type=task_type).observe(len(bundle.slices))
168
+ m.PIPELINE_TOKENS.labels(task_type=task_type).observe(bundle.total_tokens)
169
+ except asyncio.TimeoutError:
170
+ m.PIPELINE_REQUESTS.labels(task_type=task_type, status="timeout").inc()
171
+ m.ERRORS.labels(component="pipeline").inc()
172
+ raise
173
+ except Exception:
174
+ m.PIPELINE_REQUESTS.labels(task_type=task_type, status="error").inc()
175
+ m.ERRORS.labels(component="pipeline").inc()
176
+ raise
177
+
178
+ # Update session stats after each successful request
179
+ if provenance:
180
+ await provenance.update_session_stats(session_id, total_tokens=bundle.total_tokens)
181
+
182
+ return bundle
183
+
184
+ # ── Core pipeline endpoints ───────────────────────────────────────────────
185
+
186
+ @app.post("/analyze")
187
+ async def analyze(
188
+ body: AnalyzeRequest,
189
+ request: Request,
190
+ _token: APIToken = Depends(require_scope("analyze")),
191
+ ):
192
+ sid = body.session_id or str(uuid.uuid4())
193
+ bundle = await asyncio.wait_for(
194
+ _run_pipeline(body.task, sid, body.repository_root, request),
195
+ timeout=30,
196
+ )
197
+ graph = _engines.get("graph")
198
+ graph_ready = graph.health_check().healthy if graph else False
199
+ response: dict = {
200
+ "session_id": sid,
201
+ "request_id": bundle.request_id,
202
+ "task_type": bundle.task_type,
203
+ "slices": [s.model_dump() for s in bundle.slices],
204
+ "total_tokens": bundle.total_tokens,
205
+ "excluded_count": bundle.excluded_count,
206
+ "trust_range": bundle.trust_range.model_dump(),
207
+ "entropy_score": bundle.entropy_score,
208
+ }
209
+ if not graph_ready:
210
+ response["graph_status"] = "unavailable"
211
+ response["graph_message"] = "KuzuDB graph not indexed - run 'context-compiler index' on the repository first"
212
+ elif not bundle.slices:
213
+ response["graph_status"] = "no_candidates"
214
+ response["graph_message"] = "Graph is ready but no candidates matched this task - check repository_root or task description"
215
+ else:
216
+ response["graph_status"] = "ok"
217
+ return response
218
+
219
+ @app.post("/bundle")
220
+ async def bundle(
221
+ body: BundleRequest,
222
+ request: Request,
223
+ _token: APIToken = Depends(require_scope("bundle")),
224
+ ):
225
+ accept = request.headers.get("accept", "")
226
+ sid = body.session_id or str(uuid.uuid4())
227
+
228
+ if accept == "text/event-stream":
229
+ async def stream():
230
+ synthesizer = _engines.get("synthesizer")
231
+ if synthesizer:
232
+ async for event in synthesizer.assemble_streaming(body.task, sid, []):
233
+ yield f"data: {json.dumps(event)}\n\n"
234
+ return StreamingResponse(stream(), media_type="text/event-stream")
235
+
236
+ bundle_result = await asyncio.wait_for(
237
+ _run_pipeline(body.task, sid, body.repository_root, request),
238
+ timeout=60,
239
+ )
240
+ return bundle_result.model_dump()
241
+
242
+ # ── Provenance endpoints ──────────────────────────────────────────────────
243
+
244
+ @app.get("/provenance/sessions")
245
+ async def list_sessions(
246
+ limit: int = 50,
247
+ offset: int = 0,
248
+ _token: APIToken = Depends(require_auth),
249
+ ):
250
+ provenance = _engines.get("provenance")
251
+ if not provenance:
252
+ return {"sessions": [], "limit": limit, "offset": offset}
253
+ sessions = await asyncio.wait_for(
254
+ provenance.list_sessions(limit=limit, offset=offset),
255
+ timeout=10,
256
+ )
257
+ return {"sessions": sessions, "limit": limit, "offset": offset}
258
+
259
+ @app.get("/provenance/sessions/{session_id}")
260
+ async def get_session(
261
+ session_id: str,
262
+ _token: APIToken = Depends(require_auth),
263
+ ):
264
+ provenance = _engines.get("provenance")
265
+ if not provenance:
266
+ raise HTTPException(status_code=503, detail={"error": "provenance unavailable", "code": "SERVICE_UNAVAILABLE"})
267
+ session = await asyncio.wait_for(provenance.get_session(session_id), timeout=10)
268
+ if not session:
269
+ raise HTTPException(status_code=404, detail={"error": "session not found", "code": "NOT_FOUND"})
270
+ return session
271
+
272
+ @app.get("/provenance/replay")
273
+ async def replay(
274
+ session_id: str,
275
+ request_id: str | None = None,
276
+ _token: APIToken = Depends(require_auth),
277
+ ):
278
+ provenance = _engines.get("provenance")
279
+ if not provenance:
280
+ raise HTTPException(status_code=503, detail={"error": "provenance unavailable", "code": "SERVICE_UNAVAILABLE"})
281
+ events = await asyncio.wait_for(
282
+ provenance.replay(session_id, request_id),
283
+ timeout=10,
284
+ )
285
+ return {"events": events, "count": len(events)}
286
+
287
+ @app.post("/provenance/outcome")
288
+ async def record_outcome(
289
+ body: OutcomeRequest,
290
+ _token: APIToken = Depends(require_auth),
291
+ ):
292
+ provenance = _engines.get("provenance")
293
+ if provenance:
294
+ from context_firewall.provenance.models import OutcomeEvent
295
+ event = OutcomeEvent(
296
+ session_id=body.session_id,
297
+ request_id=body.request_id,
298
+ outcome_type=body.outcome_type,
299
+ success=body.success,
300
+ score=body.score,
301
+ node_ids=body.node_ids,
302
+ source_id=body.source_id,
303
+ )
304
+ await asyncio.wait_for(provenance.record_outcome(event), timeout=5)
305
+ return {"status": "recorded"}
306
+
307
+ @app.get("/provenance/latest")
308
+ async def get_latest_provenance(
309
+ limit: int = 50,
310
+ _token: APIToken = Depends(require_auth),
311
+ ):
312
+ """Latest events across all sessions - used by the live feed."""
313
+ provenance = _engines.get("provenance")
314
+ if not provenance:
315
+ return {"events": []}
316
+ events = await asyncio.wait_for(
317
+ provenance.get_latest_events(limit=limit),
318
+ timeout=10,
319
+ )
320
+ return {"events": events}
321
+
322
+ # ── WebSocket live feed ───────────────────────────────────────────────────
323
+
324
+ @app.websocket("/ws/events")
325
+ async def ws_events(websocket: WebSocket):
326
+ """Real-time enforcement and provenance event feed."""
327
+ await websocket.accept()
328
+ provenance = _engines.get("provenance")
329
+ if not provenance:
330
+ await websocket.send_json({"type": "error", "message": "provenance engine unavailable"})
331
+ await websocket.close()
332
+ return
333
+
334
+ q = provenance.subscribe_events()
335
+ try:
336
+ while True:
337
+ try:
338
+ event = await asyncio.wait_for(q.get(), timeout=30)
339
+ await websocket.send_json(event)
340
+ except asyncio.TimeoutError:
341
+ # Send heartbeat to keep connection alive
342
+ await websocket.send_json({"type": "heartbeat", "ts": datetime.now(timezone.utc).isoformat()})
343
+ except WebSocketDisconnect:
344
+ pass
345
+ except Exception as e:
346
+ logger.warning("WebSocket error", extra={"error": str(e)})
347
+ finally:
348
+ provenance.unsubscribe_events(q)
349
+
350
+ # ── Policy simulation endpoint ────────────────────────────────────────────
351
+
352
+ @app.post("/v1/policy/simulate")
353
+ async def simulate_policy(
354
+ body: dict,
355
+ _token: APIToken = Depends(require_auth),
356
+ ):
357
+ """
358
+ Simulate policy evaluation against provided content without blocking anything.
359
+ Returns what would be blocked/allowed and by which rule.
360
+ """
361
+ from context_firewall.models import RankedSlice
362
+ from context_firewall.source.types import SourceTrustTier
363
+ from context_firewall.policy.detectors.injection import detect_injection
364
+
365
+ policy = _engines.get("policy")
366
+ content = body.get("content", "")
367
+ file_path = body.get("file_path", "simulated")
368
+ tier_raw = body.get("source_tier", "untrusted")
369
+
370
+ try:
371
+ tier = SourceTrustTier(tier_raw)
372
+ except ValueError:
373
+ tier = SourceTrustTier.UNTRUSTED
374
+
375
+ candidate = RankedSlice(
376
+ node_id=f"sim:{uuid.uuid4().hex[:8]}",
377
+ file_path=file_path,
378
+ content=content,
379
+ trust_score=0.5,
380
+ token_count=len(content.split()),
381
+ source_trust_tier=tier,
382
+ )
383
+
384
+ # Run injection detection on the content regardless of policy
385
+ injection_result = detect_injection(content)
386
+
387
+ # Run through policy engine - events are tagged with sim: prefix so they
388
+ # can be filtered from the main feed; no provenance manipulation needed.
389
+ verdict = "allow"
390
+ if policy:
391
+ sim_session = f"sim:{uuid.uuid4().hex[:8]}"
392
+ sim_request = f"sim:{uuid.uuid4().hex[:8]}"
393
+ filtered, violations = await policy.evaluate([candidate], sim_request, sim_session)
394
+ if not filtered:
395
+ verdict = "block"
396
+ elif violations > 0:
397
+ verdict = "redact"
398
+
399
+ return {
400
+ "verdict": verdict,
401
+ "content_length": len(content),
402
+ "source_tier": tier.value,
403
+ "injection_detection": {
404
+ "detected": injection_result.detected,
405
+ "confidence": round(injection_result.confidence, 3),
406
+ "layer": injection_result.layer,
407
+ "signal": injection_result.signal,
408
+ "excerpt": injection_result.excerpt,
409
+ },
410
+ "would_block": verdict == "block",
411
+ "would_redact": verdict == "redact",
412
+ }
413
+
414
+ # ── Active policy rules ───────────────────────────────────────────────────
415
+
416
+ @app.get("/v1/policy/rules")
417
+ async def list_policy_rules(_token: APIToken = Depends(require_auth)):
418
+ """Return all currently active policy rules (builtin + DSL layers)."""
419
+ policy = _engines.get("policy")
420
+ if not policy:
421
+ return {"rules": [], "total": 0}
422
+ rules = policy.get_active_rules()
423
+ return {"rules": rules, "total": len(rules)}
424
+
425
+ # ── Trust score explanation endpoint ─────────────────────────────────────
426
+
427
+ @app.get("/v1/explain/{request_id}")
428
+ async def explain_request(
429
+ request_id: str,
430
+ _token: APIToken = Depends(require_auth),
431
+ ):
432
+ """
433
+ Return signal-level explanation for every slice included/excluded in a request.
434
+ Pulls from provenance events for that request_id.
435
+ """
436
+ provenance = _engines.get("provenance")
437
+ if not provenance:
438
+ raise HTTPException(status_code=503, detail={"error": "provenance unavailable"})
439
+
440
+ events = await asyncio.wait_for(
441
+ provenance.replay(session_id="", request_id=request_id),
442
+ timeout=10,
443
+ )
444
+
445
+ # If replay by session+request fails, try querying by request_id only
446
+ if not events:
447
+ db = provenance._db
448
+ if db:
449
+ async with db.execute(
450
+ "SELECT payload, event_type, occurred_at FROM provenance_events WHERE request_id = ? ORDER BY occurred_at ASC",
451
+ (request_id,),
452
+ ) as cur:
453
+ rows = await cur.fetchall()
454
+ events = [
455
+ {"event_type": r["event_type"], "occurred_at": r["occurred_at"], **json.loads(r["payload"])}
456
+ for r in rows
457
+ ]
458
+
459
+ included = [e for e in events if e.get("event_type") == "slice_included"]
460
+ excluded = [e for e in events if e.get("event_type") == "slice_excluded"]
461
+
462
+ # Also fetch enforcement events for this request
463
+ enforcements = []
464
+ db = provenance._db
465
+ if db:
466
+ async with db.execute(
467
+ """
468
+ SELECT rule_name, action, file_path, reason, pattern_name, occurred_at
469
+ FROM policy_enforcement_events WHERE request_id = ?
470
+ ORDER BY occurred_at ASC
471
+ """,
472
+ (request_id,),
473
+ ) as cur:
474
+ rows = await cur.fetchall()
475
+ enforcements = [dict(r) for r in rows]
476
+
477
+ return {
478
+ "request_id": request_id,
479
+ "included_count": len(included),
480
+ "excluded_count": len(excluded),
481
+ "enforcement_count": len(enforcements),
482
+ "included_slices": [
483
+ {
484
+ "file_path": e.get("file_path", ""),
485
+ "node_id": e.get("node_id", ""),
486
+ "trust_score": e.get("trust_score", 0),
487
+ "token_count": e.get("token_count", 0),
488
+ "include_reason": e.get("include_reason", ""),
489
+ }
490
+ for e in included
491
+ ],
492
+ "excluded_slices": [
493
+ {
494
+ "file_path": e.get("file_path", ""),
495
+ "node_id": e.get("node_id", ""),
496
+ "trust_score": e.get("trust_score", 0),
497
+ "exclude_reason": e.get("exclude_reason", ""),
498
+ }
499
+ for e in excluded
500
+ ],
501
+ "enforcements": enforcements,
502
+ }
503
+
504
+ # ── Analytics summary KPI ─────────────────────────────────────────────────
505
+
506
+ @app.get("/analytics/summary")
507
+ async def analytics_summary(
508
+ window_hours: int = 24,
509
+ _token: APIToken = Depends(require_auth),
510
+ ):
511
+ """Aggregate KPI metrics for dashboard cards."""
512
+ provenance = _engines.get("provenance")
513
+ if not provenance:
514
+ return {"blocked_artifacts": 0, "policy_violations": 0, "total_requests": 0, "active_sessions": 0}
515
+ return await asyncio.wait_for(
516
+ provenance.get_analytics_summary(window_hours=window_hours),
517
+ timeout=10,
518
+ )
519
+
520
+ # ── Other endpoints ───────────────────────────────────────────────────────
521
+
522
+ @app.post("/webhook/ci")
523
+ async def ci_webhook(
524
+ request: Request,
525
+ _token: APIToken = Depends(require_auth),
526
+ ):
527
+ payload = await request.json()
528
+ logger.info("ci_webhook received", extra={"payload_keys": list(payload.keys())})
529
+ return {"status": "received"}
530
+
531
+ @app.get("/health")
532
+ async def health():
533
+ subsystems = {}
534
+ for name, engine in _engines.items():
535
+ if hasattr(engine, "health_check"):
536
+ h = engine.health_check()
537
+ subsystems[name] = {
538
+ "healthy": h.healthy,
539
+ "status": "healthy" if h.healthy else "degraded",
540
+ "message": h.message,
541
+ }
542
+ all_healthy = all(v["healthy"] for v in subsystems.values()) if subsystems else True
543
+ return {
544
+ "status": "healthy" if all_healthy else "degraded",
545
+ "subsystems": subsystems,
546
+ "timestamp": datetime.now(timezone.utc).isoformat(),
547
+ "version": "0.1.0",
548
+ }
549
+
550
+ # ── Source Trust Registry ─────────────────────────────────────────────────
551
+
552
+ @app.post("/v1/sources")
553
+ async def register_source(
554
+ body: dict,
555
+ _token: APIToken = Depends(require_auth),
556
+ ):
557
+ registry = _engines.get("source_registry")
558
+ if not registry:
559
+ raise HTTPException(status_code=503, detail={"error": "source registry unavailable", "code": "SERVICE_UNAVAILABLE"})
560
+
561
+ from context_firewall.source.types import SourceTrustTier
562
+ source_id = body.get("id", "").strip()
563
+ source_type = body.get("type", "unknown").strip()
564
+ tier_raw = body.get("trust_tier", "untrusted")
565
+
566
+ if not source_id:
567
+ raise HTTPException(status_code=422, detail={"error": "id is required"})
568
+
569
+ valid_tiers = {t.value for t in SourceTrustTier}
570
+ if tier_raw not in valid_tiers:
571
+ raise HTTPException(
572
+ status_code=422,
573
+ detail={"error": f"invalid trust_tier '{tier_raw}'", "valid": sorted(valid_tiers)},
574
+ )
575
+
576
+ tier = SourceTrustTier(tier_raw)
577
+ source = await registry.register(
578
+ source_id,
579
+ source_type,
580
+ tier,
581
+ owner=body.get("owner", ""),
582
+ region=body.get("region", ""),
583
+ data_classification=body.get("data_classification", ""),
584
+ config=body.get("config") or {},
585
+ )
586
+ return {"status": "ok", "source": source.to_dict()}
587
+
588
+ @app.get("/v1/sources")
589
+ async def list_sources(_token: APIToken = Depends(require_auth)):
590
+ registry = _engines.get("source_registry")
591
+ if not registry:
592
+ return {"sources": []}
593
+ sources = await registry.list_sources()
594
+ provenance = _engines.get("provenance")
595
+ penalties = await provenance.get_all_source_penalties() if provenance else {}
596
+ result = []
597
+ for s in sources:
598
+ d = s.to_dict()
599
+ d["enforcement_penalty"] = penalties.get(s.id)
600
+ result.append(d)
601
+ return {"sources": result}
602
+
603
+ @app.get("/v1/sources/{source_id}/trust")
604
+ async def get_source_trust(source_id: str, _token: APIToken = Depends(require_auth)):
605
+ """Full trust health for a source: tier + live penalty score + enforcement history."""
606
+ registry = _engines.get("source_registry")
607
+ if not registry:
608
+ raise HTTPException(status_code=503, detail={"error": "source registry unavailable", "code": "SERVICE_UNAVAILABLE"})
609
+ source = await registry.get(source_id)
610
+ if not source:
611
+ raise HTTPException(status_code=404, detail={"error": "source not found", "code": "NOT_FOUND"})
612
+ provenance = _engines.get("provenance")
613
+ penalty = await provenance.get_source_penalty(source_id) if provenance else None
614
+ return {
615
+ "source_id": source_id,
616
+ "trust_tier": source.trust_tier.value,
617
+ "compliance_scope": source.compliance_scope(),
618
+ "enforcement_penalty": penalty,
619
+ "trust_health": "clean" if penalty is None else (
620
+ "degraded" if penalty["penalty_score"] >= 0.5 else
621
+ "warned" if penalty["penalty_score"] >= 0.15 else "recovering"
622
+ ),
623
+ }
624
+
625
+ @app.get("/v1/sources/{source_id}")
626
+ async def get_source(source_id: str, _token: APIToken = Depends(require_auth)):
627
+ registry = _engines.get("source_registry")
628
+ if not registry:
629
+ raise HTTPException(status_code=503, detail={"error": "source registry unavailable", "code": "SERVICE_UNAVAILABLE"})
630
+ source = await registry.get(source_id)
631
+ if not source:
632
+ raise HTTPException(status_code=404, detail={"error": "source not found", "code": "NOT_FOUND"})
633
+ provenance = _engines.get("provenance")
634
+ penalty = await provenance.get_source_penalty(source_id) if provenance else None
635
+ d = source.to_dict()
636
+ d["enforcement_penalty"] = penalty
637
+ return d
638
+
639
+ @app.patch("/v1/sources/{source_id}")
640
+ async def update_source(
641
+ source_id: str,
642
+ body: dict,
643
+ _token: APIToken = Depends(require_auth),
644
+ ):
645
+ registry = _engines.get("source_registry")
646
+ if not registry:
647
+ raise HTTPException(status_code=503, detail={"error": "source registry unavailable", "code": "SERVICE_UNAVAILABLE"})
648
+ from context_firewall.source.types import SourceTrustTier
649
+ tier = None
650
+ if "trust_tier" in body:
651
+ try:
652
+ tier = SourceTrustTier(body["trust_tier"])
653
+ except ValueError:
654
+ raise HTTPException(status_code=422, detail={"error": f"invalid trust_tier '{body['trust_tier']}'"})
655
+ updated = await registry.update(
656
+ source_id,
657
+ source_type=body.get("type"),
658
+ trust_tier=tier,
659
+ owner=body.get("owner"),
660
+ region=body.get("region"),
661
+ data_classification=body.get("data_classification"),
662
+ config=body.get("config"),
663
+ )
664
+ if not updated:
665
+ raise HTTPException(status_code=404, detail={"error": "source not found", "code": "NOT_FOUND"})
666
+ return {"status": "ok", "source": updated.to_dict()}
667
+
668
+ @app.delete("/v1/sources/{source_id}")
669
+ async def delete_source(
670
+ source_id: str,
671
+ _token: APIToken = Depends(require_auth),
672
+ ):
673
+ registry = _engines.get("source_registry")
674
+ if not registry:
675
+ raise HTTPException(status_code=503, detail={"error": "source registry unavailable", "code": "SERVICE_UNAVAILABLE"})
676
+ removed = await registry.remove(source_id)
677
+ if not removed:
678
+ raise HTTPException(status_code=404, detail={"error": "source not found", "code": "NOT_FOUND"})
679
+ provenance = _engines.get("provenance")
680
+ if provenance:
681
+ from context_firewall.provenance.models import PolicyEnforcementEvent
682
+ import uuid as _uuid
683
+ await provenance.emit_policy_enforcement(PolicyEnforcementEvent(
684
+ request_id=str(_uuid.uuid4())[:8],
685
+ session_id="system",
686
+ rule_name="source-removed",
687
+ action="audit-only",
688
+ file_path="",
689
+ reason=f"source {source_id} soft-deleted via API",
690
+ ))
691
+ return {"status": "removed", "source_id": source_id}
692
+
693
+ @app.post("/v1/filter")
694
+ async def filter_documents(
695
+ body: dict,
696
+ request: Request,
697
+ _token: APIToken = Depends(require_auth),
698
+ ):
699
+ """Filter raw documents through the trust + policy pipeline. Used by demo agent for web content."""
700
+ from context_firewall.models import RankedSlice
701
+ from context_firewall.source.types import SourceTrustTier
702
+
703
+ registry = _engines.get("source_registry")
704
+ policy = _engines.get("policy")
705
+ provenance = _engines.get("provenance")
706
+
707
+ source_id = body.get("source_id", "")
708
+ documents = body.get("documents", [])
709
+ session_id = body.get("session_id") or str(uuid.uuid4())
710
+ req_id = getattr(request.state, "request_id", str(uuid.uuid4())[:8])
711
+
712
+ tier = SourceTrustTier.UNTRUSTED
713
+ if registry and source_id:
714
+ tier = registry.get_trust_tier(source_id)
715
+
716
+ candidates = []
717
+ for i, doc in enumerate(documents):
718
+ content = doc.get("content", "") if isinstance(doc, dict) else str(doc)
719
+ url = doc.get("url", f"doc-{i}") if isinstance(doc, dict) else f"doc-{i}"
720
+ candidates.append(RankedSlice(
721
+ node_id=f"{source_id}:{i}",
722
+ file_path=url,
723
+ content=content,
724
+ trust_score=0.5,
725
+ token_count=len(content.split()),
726
+ source_id=source_id,
727
+ source_trust_tier=tier,
728
+ ))
729
+
730
+ violations = 0
731
+ allowed = candidates
732
+ if policy:
733
+ allowed, violations = await policy.evaluate(candidates, req_id, session_id)
734
+
735
+ return {
736
+ "session_id": session_id,
737
+ "source_id": source_id,
738
+ "source_trust_tier": tier.value,
739
+ "total": len(candidates),
740
+ "allowed": len(allowed),
741
+ "blocked": len(candidates) - len(allowed),
742
+ "violations": violations,
743
+ "documents": [
744
+ {
745
+ "url": d.file_path,
746
+ "content": d.content,
747
+ "source_trust_tier": d.source_trust_tier.value,
748
+ "allowed": True,
749
+ }
750
+ for d in allowed
751
+ ],
752
+ "blocked_documents": [
753
+ {
754
+ "url": c.file_path,
755
+ "source_trust_tier": c.source_trust_tier.value,
756
+ "allowed": False,
757
+ }
758
+ for c in candidates
759
+ if not any(a.file_path == c.file_path for a in allowed)
760
+ ],
761
+ }
762
+
763
+ # ── Compliance Export ─────────────────────────────────────────────────────
764
+
765
+ def _make_exporter():
766
+ from context_firewall.compliance.export import ComplianceExporter
767
+ hmac_key = config.compliance_hmac_key or "dev-key"
768
+ baa_mode = getattr(config, "compliance_baa_mode", False)
769
+ return ComplianceExporter(
770
+ db_path=config.storage.db_path,
771
+ hmac_key=hmac_key,
772
+ baa_mode=baa_mode,
773
+ )
774
+
775
+ @app.get("/v1/compliance/export/{session_id}")
776
+ async def export_compliance_by_session(
777
+ session_id: str,
778
+ framework: str | None = None,
779
+ _token: APIToken = Depends(require_auth),
780
+ ):
781
+ from context_firewall.compliance.export import ExportScope
782
+ exporter = _make_exporter()
783
+ scope = ExportScope(session_id=session_id, framework=framework)
784
+ bundle = await asyncio.wait_for(exporter.export(scope), timeout=30)
785
+ return bundle.to_dict()
786
+
787
+ @app.post("/v1/compliance/export")
788
+ async def export_compliance(
789
+ body: dict,
790
+ _token: APIToken = Depends(require_auth),
791
+ ):
792
+ from context_firewall.compliance.export import ExportScope
793
+ exporter = _make_exporter()
794
+ scope = ExportScope(
795
+ session_id=body.get("session_id"),
796
+ from_ts=body.get("from_ts"),
797
+ to_ts=body.get("to_ts"),
798
+ framework=body.get("framework"),
799
+ )
800
+ bundle = await asyncio.wait_for(exporter.export(scope), timeout=30)
801
+ return bundle.to_dict()
802
+
803
+ @app.get("/v1/compliance/verify/{bundle_id}")
804
+ async def verify_compliance_bundle(
805
+ bundle_id: str,
806
+ _token: APIToken = Depends(require_auth),
807
+ ):
808
+ return {
809
+ "bundle_id": bundle_id,
810
+ "status": "use POST /v1/compliance/verify with full bundle body",
811
+ }
812
+
813
+ @app.post("/v1/compliance/verify")
814
+ async def verify_compliance_bundle_body(
815
+ body: dict,
816
+ _token: APIToken = Depends(require_auth),
817
+ ):
818
+ from context_firewall.compliance.export import (
819
+ ChainProof, ComplianceExportBundle, ExportScope, RetentionPolicy, TenantMetadata
820
+ )
821
+ exporter = _make_exporter()
822
+ try:
823
+ chain_proof_data = body.get("chain_proof", {})
824
+ cp = ChainProof(**chain_proof_data)
825
+ bundle = ComplianceExportBundle(
826
+ bundle_id=body.get("bundle_id", ""),
827
+ export_scope=body.get("export_scope", {}),
828
+ provenance_entries=body.get("provenance_entries", []),
829
+ chain_proof=cp,
830
+ control_mappings=body.get("control_mappings", []),
831
+ tenant_metadata=TenantMetadata(**body.get("tenant_metadata", {})),
832
+ retention_policy=RetentionPolicy(**body.get("retention_policy", {})),
833
+ public_key_pem=body.get("public_key_pem", ""),
834
+ signature=body.get("signature", ""),
835
+ )
836
+ except Exception as e:
837
+ raise HTTPException(status_code=422, detail={"error": f"invalid bundle: {e}"})
838
+ ok, message = await asyncio.wait_for(exporter.verify(bundle), timeout=10)
839
+ return {"valid": ok, "message": message, "bundle_id": bundle.bundle_id}
840
+
841
+ @app.get("/status")
842
+ async def status():
843
+ return {
844
+ "version": "0.1.0",
845
+ "repository_root": config.repository_root,
846
+ "engines": list(_engines.keys()),
847
+ }
848
+
849
+ @app.get("/analytics/retrieval")
850
+ async def analytics_retrieval(_token: APIToken = Depends(require_auth)):
851
+ analytics = _engines.get("analytics")
852
+ if not analytics:
853
+ return {"data": [], "error": "analytics engine unavailable"}
854
+ return await asyncio.wait_for(analytics.get_retrieval_metrics(), timeout=10)
855
+
856
+ @app.get("/analytics/entropy")
857
+ async def analytics_entropy(_token: APIToken = Depends(require_auth)):
858
+ analytics = _engines.get("analytics")
859
+ if not analytics:
860
+ return {"data": [], "error": "analytics engine unavailable"}
861
+ return await asyncio.wait_for(analytics.get_entropy_trends(), timeout=10)
862
+
863
+ @app.get("/analytics/trust-degradation")
864
+ async def analytics_trust_degradation(_token: APIToken = Depends(require_auth)):
865
+ analytics = _engines.get("analytics")
866
+ if not analytics:
867
+ return {"data": [], "error": "analytics engine unavailable"}
868
+ return await asyncio.wait_for(analytics.get_trust_degradation(), timeout=10)
869
+
870
+ @app.get("/analytics/budget-utilization")
871
+ async def analytics_budget(_token: APIToken = Depends(require_auth)):
872
+ analytics = _engines.get("analytics")
873
+ if not analytics:
874
+ return {"data": [], "error": "analytics engine unavailable"}
875
+ return await asyncio.wait_for(analytics.get_budget_utilization(), timeout=10)
876
+
877
+ @app.get("/analytics/injection-layers")
878
+ async def injection_layer_breakdown(
879
+ window_hours: int = 24,
880
+ _token: APIToken = Depends(require_auth),
881
+ ):
882
+ """Breakdown of proxy injection blocks by detection layer.
883
+
884
+ Returns counts for:
885
+ - layer1_structural (bidi chars, zero-width, template markers)
886
+ - layer2_regex (normalized pattern matching)
887
+ - layer3_heuristic (semantic feature scoring - catches paraphrases)
888
+ - base_scanner (original regex scanner: prompt_injection, PII, secrets)
889
+
890
+ High demo value: shows what Layer 3 caught that L1+L2 missed.
891
+ """
892
+ provenance = _engines.get("provenance")
893
+ if not provenance or not provenance._db:
894
+ return {
895
+ "window_hours": window_hours,
896
+ "layer1_structural": 0,
897
+ "layer2_regex": 0,
898
+ "layer3_heuristic": 0,
899
+ "base_scanner": 0,
900
+ "total_injection_blocks": 0,
901
+ }
902
+
903
+ import re as _re
904
+ from datetime import timedelta
905
+ cutoff = (datetime.now(timezone.utc) - timedelta(hours=window_hours)).isoformat()
906
+
907
+ async with provenance._db.execute(
908
+ """
909
+ SELECT reason FROM policy_enforcement_events
910
+ WHERE action = 'deny' AND occurred_at >= ?
911
+ """,
912
+ (cutoff,),
913
+ ) as cur:
914
+ rows = await cur.fetchall()
915
+
916
+ _VIOLATIONS_RE = _re.compile(r"violations=\[([^\]]*)\]")
917
+ counts: dict[str, int] = {
918
+ "layer1_structural": 0,
919
+ "layer2_regex": 0,
920
+ "layer3_heuristic": 0,
921
+ "base_scanner": 0,
922
+ }
923
+ _LAYER_MAP = {"structural": "layer1_structural", "regex": "layer2_regex", "heuristic": "layer3_heuristic"}
924
+ _SCANNER_PREFIXES = ("prompt_injection", "secret_leakage:", "pii:")
925
+
926
+ for row in rows:
927
+ reason: str = row["reason"] or ""
928
+ m = _VIOLATIONS_RE.search(reason)
929
+ if not m:
930
+ continue
931
+ for viol in m.group(1).split(","):
932
+ viol = viol.strip()
933
+ if not viol or viol == "none":
934
+ continue
935
+ if viol.startswith("injection_heuristic:"):
936
+ # format: injection_heuristic:{layer}:{signal}
937
+ parts = viol.split(":", 2)
938
+ layer_key = _LAYER_MAP.get(parts[1] if len(parts) > 1 else "", "")
939
+ if layer_key:
940
+ counts[layer_key] += 1
941
+ elif any(viol.startswith(p) for p in _SCANNER_PREFIXES):
942
+ counts["base_scanner"] += 1
943
+
944
+ total = sum(counts.values())
945
+ return {
946
+ "window_hours": window_hours,
947
+ **counts,
948
+ "total_injection_blocks": total,
949
+ }
950
+
951
+ # ── Lint ──────────────────────────────────────────────────────────────────
952
+
953
+ @app.get("/v1/lint/latest")
954
+ async def lint_latest(_token: APIToken = Depends(require_auth)):
955
+ lint = _engines.get("lint")
956
+ if lint is None:
957
+ raise HTTPException(status_code=503, detail="lint engine unavailable")
958
+ result = await lint.get_latest()
959
+ if result is None:
960
+ return {"ran_at": None, "findings": [], "summary": {}, "window_days": 30}
961
+ return result
962
+
963
+ @app.post("/v1/lint/run")
964
+ async def lint_run(
965
+ window_days: int = 30,
966
+ _token: APIToken = Depends(require_scope("admin")),
967
+ ):
968
+ lint = _engines.get("lint")
969
+ if lint is None:
970
+ raise HTTPException(status_code=503, detail="lint engine unavailable")
971
+ report = await lint.run(window_days=window_days)
972
+ return report.as_dict()
973
+
974
+ # ── Transparent proxy ─────────────────────────────────────────────────────
975
+
976
+ from context_firewall.proxy.tokens import TokenStore
977
+ from context_firewall.proxy.router import create_proxy_router
978
+
979
+ _token_store = TokenStore(db=None) # DB injected at startup
980
+ proxy_router = create_proxy_router(_token_store, _engines)
981
+ app.include_router(proxy_router)
982
+
983
+ # ── Prometheus metrics ────────────────────────────────────────────────────
984
+
985
+ @app.get("/metrics", include_in_schema=False)
986
+ async def prometheus_metrics():
987
+ """Prometheus text-format scrape endpoint. No auth required (ops standard)."""
988
+ from prometheus_client import generate_latest, CONTENT_TYPE_LATEST
989
+ from context_firewall import metrics as m
990
+
991
+ # Refresh gauges from live DB state before each scrape
992
+ provenance = _engines.get("provenance")
993
+ if provenance:
994
+ try:
995
+ sessions = await provenance.list_sessions(limit=1000)
996
+ active = sum(1 for s in sessions if s.get("status") == "active")
997
+ m.ACTIVE_SESSIONS.set(active)
998
+ except Exception:
999
+ pass
1000
+
1001
+ if _token_store._db is not None:
1002
+ try:
1003
+ keys = await _token_store.list_tokens()
1004
+ m.PROXY_KEYS_ACTIVE.set(len(keys))
1005
+ except Exception:
1006
+ pass
1007
+
1008
+ return Response(
1009
+ content=generate_latest(),
1010
+ media_type=CONTENT_TYPE_LATEST,
1011
+ )
1012
+
1013
+ @app.on_event("startup")
1014
+ async def _init_proxy_db():
1015
+ from context_firewall.db.connection import get_db
1016
+ db = await get_db()
1017
+ _token_store.set_db(db)
1018
+ logger.info("proxy token store ready")
1019
+
1020
+ return app