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.
- context_firewall/__init__.py +3 -0
- context_firewall/analytics/__init__.py +5 -0
- context_firewall/analytics/engine.py +234 -0
- context_firewall/api/__init__.py +5 -0
- context_firewall/api/app.py +1020 -0
- context_firewall/api/models.py +54 -0
- context_firewall/classifier/__init__.py +5 -0
- context_firewall/classifier/classifier.py +165 -0
- context_firewall/cli/__init__.py +5 -0
- context_firewall/cli/main.py +513 -0
- context_firewall/compliance/__init__.py +1 -0
- context_firewall/compliance/baa.py +57 -0
- context_firewall/compliance/chain.py +84 -0
- context_firewall/compliance/control_mappings.py +92 -0
- context_firewall/compliance/export.py +280 -0
- context_firewall/compliance/keys.py +94 -0
- context_firewall/config.py +234 -0
- context_firewall/control_plane/__init__.py +0 -0
- context_firewall/control_plane/client.py +107 -0
- context_firewall/control_plane/models.py +72 -0
- context_firewall/control_plane/pusher.py +377 -0
- context_firewall/daemon/__init__.py +5 -0
- context_firewall/daemon/main.py +461 -0
- context_firewall/db/__init__.py +6 -0
- context_firewall/db/connection.py +30 -0
- context_firewall/db/migrations.py +408 -0
- context_firewall/entropy/__init__.py +5 -0
- context_firewall/entropy/engine.py +271 -0
- context_firewall/graph/__init__.py +5 -0
- context_firewall/graph/engine.py +162 -0
- context_firewall/lint/__init__.py +0 -0
- context_firewall/lint/engine.py +334 -0
- context_firewall/mcp/__init__.py +5 -0
- context_firewall/mcp/server.py +237 -0
- context_firewall/metrics.py +100 -0
- context_firewall/models.py +130 -0
- context_firewall/policy/__init__.py +10 -0
- context_firewall/policy/detectors/__init__.py +0 -0
- context_firewall/policy/detectors/injection.py +480 -0
- context_firewall/policy/dsl/__init__.py +0 -0
- context_firewall/policy/dsl/evaluator.py +208 -0
- context_firewall/policy/dsl/loader.py +150 -0
- context_firewall/policy/dsl/types.py +112 -0
- context_firewall/policy/engine.py +532 -0
- context_firewall/provenance/__init__.py +5 -0
- context_firewall/provenance/engine.py +658 -0
- context_firewall/provenance/models.py +91 -0
- context_firewall/proxy/__init__.py +1 -0
- context_firewall/proxy/router.py +519 -0
- context_firewall/proxy/scanner.py +214 -0
- context_firewall/proxy/tokens.py +156 -0
- context_firewall/runtime/__init__.py +5 -0
- context_firewall/runtime/engine.py +303 -0
- context_firewall/source/__init__.py +1 -0
- context_firewall/source/registry.py +299 -0
- context_firewall/source/types.py +12 -0
- context_firewall/synthesizer/__init__.py +5 -0
- context_firewall/synthesizer/synthesizer.py +255 -0
- context_firewall/trust/__init__.py +5 -0
- context_firewall/trust/engine.py +139 -0
- context_firewall/trust/signals.py +184 -0
- contextwall-0.1.0.dist-info/METADATA +431 -0
- contextwall-0.1.0.dist-info/RECORD +66 -0
- contextwall-0.1.0.dist-info/WHEEL +4 -0
- contextwall-0.1.0.dist-info/entry_points.txt +3 -0
- 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
|