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,461 @@
1
+ """ContextWall Daemon - single-process asyncio event loop embedding all engines."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+ import signal
9
+ import subprocess
10
+ import sys
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+
14
+ import uvicorn
15
+ from apscheduler.schedulers.asyncio import AsyncIOScheduler
16
+
17
+ from context_firewall.config import Config, load_config
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ logging.basicConfig(
22
+ level=logging.INFO,
23
+ format="%(asctime)s %(levelname)s %(name)s %(message)s",
24
+ )
25
+
26
+
27
+ async def _seed_git_metadata(db_path: str, repository_root: str) -> None:
28
+ """
29
+ Populate git_metadata table from git history for the indexed repository.
30
+
31
+ This runs on startup so trust scoring has real freshness and stability
32
+ signals instead of defaulting everything to 0.5 (neutral).
33
+ """
34
+ import aiosqlite
35
+
36
+ if not repository_root or not Path(repository_root).exists():
37
+ logger.info("git metadata seed skipped: no repository root")
38
+ return
39
+
40
+ try:
41
+ # git log: output format is "ISO_DATE FILEPATH" for each file changed per commit
42
+ result = subprocess.run(
43
+ [
44
+ "git", "log",
45
+ "--name-only",
46
+ "--format=COMMIT %ai",
47
+ "--no-merges",
48
+ "--diff-filter=ACMR", # Added, Copied, Modified, Renamed
49
+ "-500", # last 500 commits
50
+ ],
51
+ cwd=repository_root,
52
+ capture_output=True,
53
+ text=True,
54
+ timeout=30,
55
+ )
56
+ if result.returncode != 0:
57
+ logger.info("git log unavailable: %s", result.stderr[:100])
58
+ return
59
+
60
+ # Parse: track last commit date and commit count per file in last 30 days
61
+ file_last_commit: dict[str, str] = {}
62
+ file_commit_count_30d: dict[str, int] = {}
63
+ now = datetime.now(timezone.utc)
64
+
65
+ current_date: str | None = None
66
+ for line in result.stdout.splitlines():
67
+ line = line.strip()
68
+ if not line:
69
+ continue
70
+ if line.startswith("COMMIT "):
71
+ current_date = line[7:].strip()
72
+ elif current_date and not line.startswith("commit "):
73
+ filepath = line
74
+ if filepath not in file_last_commit:
75
+ file_last_commit[filepath] = current_date
76
+ # Count commits in last 30 days
77
+ try:
78
+ commit_dt = datetime.fromisoformat(current_date)
79
+ if commit_dt.tzinfo is None:
80
+ commit_dt = commit_dt.replace(tzinfo=timezone.utc)
81
+ age_days = (now - commit_dt).days
82
+ if age_days <= 30:
83
+ file_commit_count_30d[filepath] = file_commit_count_30d.get(filepath, 0) + 1
84
+ except ValueError:
85
+ pass
86
+
87
+ if not file_last_commit:
88
+ logger.info("git metadata: no commits found")
89
+ return
90
+
91
+ indexed_at = now.isoformat()
92
+ async with aiosqlite.connect(db_path) as db:
93
+ await db.executemany(
94
+ """
95
+ INSERT OR REPLACE INTO git_metadata
96
+ (file_path, last_commit_at, commit_count_30d, author_count, indexed_at)
97
+ VALUES (?, ?, ?, ?, ?)
98
+ """,
99
+ [
100
+ (
101
+ fp,
102
+ file_last_commit[fp],
103
+ file_commit_count_30d.get(fp, 0),
104
+ 1, # author_count: would need git log --format=%ae to compute exactly
105
+ indexed_at,
106
+ )
107
+ for fp in file_last_commit
108
+ ],
109
+ )
110
+ await db.commit()
111
+
112
+ logger.info(
113
+ "git metadata seeded",
114
+ extra={"files": len(file_last_commit), "repo": repository_root},
115
+ )
116
+ except subprocess.TimeoutExpired:
117
+ logger.warning("git metadata seed timed out")
118
+ except Exception as e:
119
+ logger.warning("git metadata seed failed (non-fatal)", extra={"error": str(e)})
120
+
121
+
122
+ async def run_daemon(config: Config, host: str = "0.0.0.0") -> None:
123
+ db_path = config.storage.db_path
124
+ Path(db_path).parent.mkdir(parents=True, exist_ok=True)
125
+
126
+ # ── Startup validation ────────────────────────────────────────────────────
127
+ if config.rest_api.auth.enabled:
128
+ tokens = config.rest_api.auth.tokens
129
+ if not tokens:
130
+ logger.error(
131
+ "STARTUP BLOCKED: auth.enabled=true but no tokens configured. "
132
+ "Add at least one token to ctxfw.yaml or set CRE_API_TOKEN."
133
+ )
134
+ sys.exit(1)
135
+ _WEAK = {"", "changeme", "demo-token", "secret", "password", "token", "${CRE_API_TOKEN}"}
136
+ weak = [t for t in tokens if t.get("token", "") in _WEAK]
137
+ if weak:
138
+ logger.error(
139
+ "STARTUP BLOCKED: auth token is set to a known-weak default ('%s'). "
140
+ "Set a real random secret in ctxfw.yaml or via the CRE_API_TOKEN env var. "
141
+ "Generate one with: python3 -c \"import secrets; print(secrets.token_urlsafe(32))\"",
142
+ weak[0].get("token"),
143
+ )
144
+ sys.exit(1)
145
+
146
+ if not config.compliance_hmac_key:
147
+ logger.warning(
148
+ "compliance_hmac_key not set - compliance bundles will use a dev key and are NOT "
149
+ "tamper-evident. Set CRE_COMPLIANCE_HMAC_KEY or compliance_hmac_key in ctxfw.yaml. "
150
+ "Generate one with: python3 -c \"import secrets; print(secrets.token_urlsafe(32))\""
151
+ )
152
+
153
+ # ── DB path configuration ─────────────────────────────────────────────────
154
+ from context_firewall.db.connection import configure_db_path
155
+ configure_db_path(db_path)
156
+
157
+ # ── Schema migrations ──────────────────────────────────────────────────────
158
+ from context_firewall.db.migrations import run_migrations
159
+ await run_migrations(db_path)
160
+ logger.info("SQLite migrations complete", extra={"db_path": db_path})
161
+
162
+ # ── Instantiate engines ───────────────────────────────────────────────────
163
+ from context_firewall.classifier.classifier import classify_task, build_pipeline_context
164
+ from context_firewall.graph.engine import RepositoryGraphEngine
165
+ from context_firewall.trust.engine import TrustScoringEngine
166
+ from context_firewall.entropy.engine import ContextEntropyEngine
167
+ from context_firewall.runtime.engine import RuntimeCorrelationEngine
168
+ from context_firewall.policy.engine import PolicyEngine
169
+ from context_firewall.provenance.engine import ProvenanceEngine
170
+ from context_firewall.synthesizer.synthesizer import ContextSynthesizer
171
+ from context_firewall.analytics.engine import AnalyticsEngine
172
+ from context_firewall.lint.engine import LintEngine
173
+
174
+ # Wrap classify_task to match engine protocol
175
+ _classify_task_fn = classify_task # capture before class body redefines the name
176
+
177
+ class _ClassifierShim:
178
+ name = "intent_classifier"
179
+ critical = False
180
+ classify_task = staticmethod(_classify_task_fn)
181
+
182
+ async def init(self, cfg: Config) -> None:
183
+ pass
184
+
185
+ def health_check(self):
186
+ from context_firewall.models import SubsystemHealth
187
+ return SubsystemHealth(name=self.name, healthy=True)
188
+
189
+ async def shutdown(self) -> None:
190
+ pass
191
+
192
+ # ── Apply detection thresholds from config ────────────────────────────────
193
+ from context_firewall.policy.detectors.injection import configure_thresholds
194
+ configure_thresholds(
195
+ block=config.detection.injection_block_threshold,
196
+ warn=config.detection.injection_warn_threshold,
197
+ )
198
+ logger.info(
199
+ "Injection thresholds: block=%.2f warn=%.2f",
200
+ config.detection.injection_block_threshold,
201
+ config.detection.injection_warn_threshold,
202
+ )
203
+
204
+ from context_firewall.source.registry import SourceRegistry
205
+ from context_firewall.source.types import SourceTrustTier
206
+ source_registry = SourceRegistry(
207
+ db_path,
208
+ classification_frameworks=config.compliance.classification_frameworks,
209
+ )
210
+ await source_registry.init()
211
+
212
+ # Register declarative sources from config
213
+ if config.sources:
214
+ for src in config.sources:
215
+ await source_registry.register(
216
+ src.id,
217
+ src.type,
218
+ SourceTrustTier(src.trust_tier),
219
+ owner=src.owner,
220
+ region=src.region,
221
+ data_classification=src.data_classification,
222
+ config=src.config or {},
223
+ )
224
+ logger.info("Registered %d source(s) from config", len(config.sources))
225
+
226
+ repo_root = config.repository_root
227
+ if repo_root:
228
+ count = await source_registry.auto_register_repo_sources([repo_root])
229
+ if count:
230
+ logger.info("Auto-registered %d repo source(s) as internal", count)
231
+ logger.info("SourceRegistry initialized")
232
+
233
+ graph = RepositoryGraphEngine()
234
+ trust = TrustScoringEngine()
235
+ entropy = ContextEntropyEngine()
236
+ runtime = RuntimeCorrelationEngine()
237
+ policy = PolicyEngine()
238
+ provenance = ProvenanceEngine()
239
+ synthesizer = ContextSynthesizer()
240
+ analytics = AnalyticsEngine()
241
+ lint = LintEngine()
242
+ classifier_shim = _ClassifierShim()
243
+
244
+ startup_order = [
245
+ graph,
246
+ trust,
247
+ entropy,
248
+ runtime,
249
+ policy,
250
+ provenance,
251
+ synthesizer,
252
+ analytics,
253
+ lint,
254
+ classifier_shim,
255
+ ]
256
+ initialized = []
257
+
258
+ for engine in startup_order:
259
+ try:
260
+ if engine.name == "policy_engine":
261
+ await engine.init(config, provenance)
262
+ elif engine.name == "context_synthesizer":
263
+ await engine.init(config, provenance)
264
+ else:
265
+ await engine.init(config)
266
+ initialized.append(engine)
267
+ # Check actual health after init - some engines (e.g. graph) init successfully
268
+ # even when their backing store isn't ready, and only report degraded via health_check.
269
+ if hasattr(engine, "health_check"):
270
+ h = engine.health_check()
271
+ if h.healthy:
272
+ logger.info("%s ready", engine.name)
273
+ else:
274
+ logger.warning("%s degraded: %s", engine.name, h.message or "check configuration")
275
+ else:
276
+ logger.info("%s ready", engine.name)
277
+ except Exception as e:
278
+ if engine.critical:
279
+ logger.error("Critical engine %s failed; shutting down: %s", engine.name, e)
280
+ for done in reversed(initialized):
281
+ try:
282
+ await done.shutdown()
283
+ except Exception:
284
+ pass
285
+ sys.exit(1)
286
+ else:
287
+ logger.warning("Non-critical engine %s failed (continuing): %s", engine.name, e)
288
+
289
+ # ── Startup health summary ─────────────────────────────────────────────────
290
+ degraded = []
291
+ for eng in initialized:
292
+ if hasattr(eng, "health_check"):
293
+ h = eng.health_check()
294
+ if not h.healthy:
295
+ degraded.append((eng.name, h.message or "degraded"))
296
+
297
+ if degraded:
298
+ logger.warning("━" * 60)
299
+ logger.warning("ContextWall started in REDUCED CAPACITY - %d subsystem(s) degraded:", len(degraded))
300
+ for name, msg in degraded:
301
+ logger.warning(" ✗ %-38s %s", name, msg)
302
+ logger.warning("Proxy enforcement and policy engine are fully operational.")
303
+ logger.warning("━" * 60)
304
+ else:
305
+ logger.info("All subsystems healthy")
306
+
307
+ # ── Seed git metadata so trust scoring has real signals on day one ─────────
308
+ if repo_root:
309
+ asyncio.ensure_future(_seed_git_metadata(db_path, repo_root))
310
+
311
+ engines = {
312
+ "classifier": classifier_shim,
313
+ "graph": graph,
314
+ "trust": trust,
315
+ "entropy": entropy,
316
+ "runtime": runtime,
317
+ "policy": policy,
318
+ "provenance": provenance,
319
+ "synthesizer": synthesizer,
320
+ "analytics": analytics,
321
+ "lint": lint,
322
+ "source_registry": source_registry,
323
+ }
324
+
325
+ # ── Control plane pusher (optional, non-critical) ─────────────────────────
326
+ from context_firewall.control_plane.pusher import ControlPlanePusher
327
+
328
+ def _health_snapshot() -> dict:
329
+ snap = {}
330
+ for eng in initialized:
331
+ if hasattr(eng, "health_check"):
332
+ h = eng.health_check()
333
+ snap[eng.name] = {
334
+ "status": "healthy" if h.healthy else "degraded",
335
+ "latency_ms": getattr(h, "latency_ms", None),
336
+ "message": getattr(h, "message", None),
337
+ }
338
+ return snap
339
+
340
+ cp_pusher = ControlPlanePusher(
341
+ cp_config=config.control_plane,
342
+ config=config,
343
+ health_provider=_health_snapshot,
344
+ policy_engine=policy,
345
+ )
346
+ await cp_pusher.start()
347
+
348
+ # ── FastAPI app ───────────────────────────────────────────────────────────
349
+ from context_firewall.api.app import create_app
350
+ app = create_app(config, engines)
351
+
352
+ # ── Offline job scheduler ────────────────────────────────────────────────
353
+ scheduler = AsyncIOScheduler()
354
+ _setup_jobs(scheduler, config, engines)
355
+ scheduler.start()
356
+ logger.info("APScheduler started")
357
+
358
+ # ── PID file ──────────────────────────────────────────────────────────────
359
+ pid_file = Path(config.daemon.pid_file)
360
+ pid_file.parent.mkdir(parents=True, exist_ok=True)
361
+ pid_file.write_text(str(os.getpid()))
362
+
363
+ # ── Signal handlers ───────────────────────────────────────────────────────
364
+ loop = asyncio.get_event_loop()
365
+
366
+ async def _shutdown():
367
+ logger.info("Graceful shutdown initiated")
368
+ scheduler.shutdown(wait=False)
369
+ await cp_pusher.stop()
370
+ for eng in reversed(initialized):
371
+ try:
372
+ await eng.shutdown()
373
+ except Exception:
374
+ pass
375
+ pid_file.unlink(missing_ok=True)
376
+ loop.stop()
377
+
378
+ for sig in (signal.SIGTERM, signal.SIGINT):
379
+ loop.add_signal_handler(sig, lambda: asyncio.ensure_future(_shutdown()))
380
+
381
+ # ── uvicorn ───────────────────────────────────────────────────────────────
382
+ uconfig = uvicorn.Config(
383
+ app,
384
+ host=host,
385
+ port=config.rest_api.port,
386
+ log_level="warning",
387
+ loop="asyncio",
388
+ )
389
+ server = uvicorn.Server(uconfig)
390
+ logger.info(f"ContextWall daemon listening on {host}:{config.rest_api.port}")
391
+ await server.serve()
392
+
393
+
394
+ def _setup_jobs(scheduler: AsyncIOScheduler, config: Config, engines: dict) -> None:
395
+ analytics = engines.get("analytics")
396
+ entropy = engines.get("entropy")
397
+ lint = engines.get("lint")
398
+
399
+ if analytics:
400
+ scheduler.add_job(
401
+ analytics.compute_and_store_snapshots,
402
+ "cron",
403
+ **_parse_cron(config.daemon.jobs.get("entropy_computation", None)),
404
+ id="analytics_snapshots",
405
+ replace_existing=True,
406
+ )
407
+
408
+ if entropy:
409
+ async def _run_entropy():
410
+ await entropy.run_analysis(config.repository_root)
411
+
412
+ scheduler.add_job(
413
+ _run_entropy,
414
+ "cron",
415
+ **_parse_cron(config.daemon.jobs.get("entropy_computation", None)),
416
+ id="entropy_computation",
417
+ replace_existing=True,
418
+ )
419
+
420
+ if lint:
421
+ async def _run_lint():
422
+ await lint.run(window_days=30)
423
+
424
+ scheduler.add_job(
425
+ _run_lint,
426
+ "cron",
427
+ # Run once daily at 03:00 UTC - light query load, results ready for morning review
428
+ hour="3",
429
+ minute="0",
430
+ id="lint_audit",
431
+ replace_existing=True,
432
+ )
433
+
434
+
435
+ def _parse_cron(job_cfg) -> dict:
436
+ """Convert cron string like '0 */6 * * *' to APScheduler kwargs."""
437
+ if job_cfg is None:
438
+ return {"minute": "0", "hour": "*/6"}
439
+ cron_str = getattr(job_cfg, "schedule", "0 */6 * * *")
440
+ parts = cron_str.split()
441
+ if len(parts) == 5:
442
+ minute, hour, day, month, day_of_week = parts
443
+ return {
444
+ "minute": minute,
445
+ "hour": hour,
446
+ "day": day,
447
+ "month": month,
448
+ "day_of_week": day_of_week,
449
+ }
450
+ return {"minute": "0", "hour": "*/6"}
451
+
452
+
453
+ def main():
454
+ import os
455
+ config_path = os.environ.get("CTXFW_CONFIG", "ctxfw.yaml")
456
+ config = load_config(config_path)
457
+ asyncio.run(run_daemon(config))
458
+
459
+
460
+ if __name__ == "__main__":
461
+ main()
@@ -0,0 +1,6 @@
1
+ """Database layer - SQLite via aiosqlite."""
2
+
3
+ from context_firewall.db.connection import get_db, close_db
4
+ from context_firewall.db.migrations import run_migrations
5
+
6
+ __all__ = ["get_db", "close_db", "run_migrations"]
@@ -0,0 +1,30 @@
1
+ """Shared aiosqlite connection manager."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import aiosqlite
6
+
7
+ _db: aiosqlite.Connection | None = None
8
+ _db_path: str = ".ctxfw/cre.db"
9
+
10
+
11
+ async def get_db() -> aiosqlite.Connection:
12
+ global _db
13
+ if _db is None:
14
+ _db = await aiosqlite.connect(_db_path)
15
+ _db.row_factory = aiosqlite.Row
16
+ await _db.execute("PRAGMA journal_mode=WAL")
17
+ await _db.execute("PRAGMA foreign_keys=ON")
18
+ return _db
19
+
20
+
21
+ async def close_db() -> None:
22
+ global _db
23
+ if _db is not None:
24
+ await _db.close()
25
+ _db = None
26
+
27
+
28
+ def configure_db_path(path: str) -> None:
29
+ global _db_path
30
+ _db_path = path