# Project export: Sentinel

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: TreeHacks 2026
- Tagline: AI Agents That Hunt Vulnerabilities While You Watch
- Devpost: https://devpost.com/software/sentinel-zor2uw
- GitHub: https://github.com/EKasuti/sentinel
- Video: https://www.youtube.com/embed/jri51Aul21k?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 5 GitHub contributor(s) — Emmanuel Kasuti Makau (30 commits), KIKW12 (9 commits), google-labs-jules[bot] (3 commits), Fabrizio Vanzani (2 commits), Claude Sonnet 4.5 (1 commits)

## Devpost submission (written by the team)

### Inspiration

We started this hackathon thinking about how hard it is for small teams to stay secure. We’ve all seen the headlines: a leaked API key here, a misconfigured CORS policy there. But the 'aha!' moment came when we realized that while hackers use automated tools, developers are still stuck manually checking dashboards. We wanted to flip the script. What if we didn't just build a scanner, but a swarm of specialized experts? We wanted to create something that doesn't just find holes, but thinks like a red teamer to prove they're real.

### What it does

Sentinel is a full-stack security platform that deploys a swarm of 10 specialized AI agents against a target URL, each responsible for a different attack surface: The agents run concurrently on the backend, report findings to a shared database in real-time, and the results are synthesized into a Gemini-powered remediation report — complete with risk grades, code-level fix instructions, and OWASP references.

### How we built it

How We Built It Tech Stack Frontend: Next.js + TypeScript with a dark cyber-security aesthetic (glassmorphism, neon accents) Backend: Python + Flask for the REST API Agents: Python asyncio with Playwright for browser automation and aiohttp for HTTP probing AI: Google Gemini (gemini-2.0-flash) for the Red Team agent's reasoning loop and report generation Database: Supabase (PostgreSQL) with Realtime subscriptions for live agent event streaming Deployment: Render (single-process multiprocessing setup running both API + worker) The Agent Framework Every agent extends a BaseAgent abstract class that provides: Lifecycle management — automatic QUEUED → RUNNING → COMPLETED/FAILED state transitions Event emission — structured events streamed to the frontend via Supabase Realtime Finding reporting — severity-tagged vulnerabilities with reproduction steps Progress tracking — percentage-based progress updates for the UI The Red Team agent is the most complex — it's an autonomous AI loop that: Launches a headless Chromium browser via Playwright Performs deep passive reconnaissance (cookie analysis, JS source scanning, API endpoint discovery) Enters an observe → think → act cycle powered by Gemini, deciding which tools to invoke (click, type, run JavaScript, make API requests, take screenshots) Reports findings with full reproduction steps Worker Orchestration The worker runs agents in three phases to balance thoroughness and rate limits: Phase 1 — Spider runs first to map the attack surface Phase 2 — Scanner agents (Exposure, Headers, CORS, Port Scan, SQLi, XSS, Auth Abuse) run concurrently via asyncio.gather() Phase 3 — LLM agents (Red Team, LLM Analysis) run sequentially to avoid API rate limit contention

### Challenges we ran into

1. LLM Rate Limits vs. Agent Concurrency Our first design ran all agents in parallel — including multiple LLM-powered ones. We immediately hit Gemini's requests-per-minute limits, causing agents to crash mid-scan. The fix was the phased orchestration model: fast scanner agents run concurrently, but LLM agents run one at a time. 2. Making the Red Team Agent Actually Useful Early versions of the Red Team agent were essentially random clickers. Getting an LLM to systematically probe a website required careful prompt engineering: we had to teach it to prioritize (e.g., check for exposed .env files before fuzzing form inputs), stay on-domain (we added a domain guard to prevent it from navigating away), and avoid infinite loops (capping the observe-think-act cycle). 3. Keeping the UI in Sync With 10+ agents running asynchronously and emitting events at different rates, keeping the frontend in sync was non-trivial. Supabase Realtime solved the transport problem, but we still had to design the event schema carefully — every event carries a run_id, agent_type, and structured data payload so the frontend can correctly route updates to the right agent lane. 4. False Positives Automated scanners are notorious for false positives. Our initial XSS and SQLi agents would flag every reflected parameter as a vulnerability. We iterated on the detection heuristics, requiring agents to verify findings (e.g., confirming that injected JavaScript actually executes in the DOM) before reporting them — bringing the signal-to-noise ratio to an acceptable level. 5. Single-Process Deployment Deploying on Render's free tier meant running both the Flask API and the async worker in a single process. We used Python's multiprocessing module to spawn the worker as a child process, with graceful shutdown handling. It's not elegant, but it works — and it means the entire backend runs from a single python main.py command.

### Accomplishments we're proud of

We moved past the idea of a single "do-it-all" tool. Instead, we engineered a coordinated swarm of 10 specialized AI agents. From the Spider (mapping the terrain) to the Auth Abuse specialist, each agent has its own personality"and mission Our biggest win was the Red Team Agent. Using Gemini 2.0 Flash and Playwright, we built a loop where the AI doesn't just scan; it observes, thinks, and acts. It actually opens a browser, interacts with elements, and decides its next move based on what it sees; just like a human pen-tester would. We wanted users to feel the action. Using Supabase Realtime, we built a live event stream where users can watch live feed of agents thoughts and findings as they happen -We capped it off with an AI-driven Remediation Engine. Instead of a messy PDF of logs, you get a well structured and detailed report that grades your security from A to F and provides code-level fixes

### What we learned

Agent design is prompt engineering + systems engineering. The hardest part isn't calling the LLM API — it's designing the observation/action loop, managing state across async agents, and handling the dozen ways an agent can fail silently. Phased orchestration matters. Running everything in parallel sounds fast, but in practice, sequencing matters — reconnaissance before attack, fast checks before slow ones. Supabase Realtime is incredibly powerful for building live dashboards. Subscribing to database changes instead of polling transformed our UX. Security tools need to be skeptical of themselves. A scanner that reports 50 false positives is worse than useless — it trains users to ignore alerts. Verification > volume.

### What's next

Authentication & multi-tenancy — user accounts with scan history Scheduled recurring scans — continuous security monitoring Custom agent configuration — let users define which agents to run and with what parameters CI/CD integration — run Sentinel as a GitHub Action on every deploy

## README (from the GitHub repository)

# Sentinel — AI-Powered Autonomous Security Scanner

## Inspiration

The idea for Sentinel was born out of a simple frustration: **penetration testing is expensive, slow, and inaccessible.** Hiring a professional pen tester can cost thousands of dollars and take weeks to schedule, leaving small teams and indie developers with no way to know if their apps are actually secure — or just *hoping* they are.

We asked ourselves: *what if an AI could do what a junior pen tester does, but in minutes instead of days?*

The rise of large language models gave us the missing piece. Traditional automated scanners (like Nmap, Nikto, or OWASP ZAP) are powerful but rigid — they run predefined checks and can't *reason* about what they find. A real pen tester doesn't just scan headers; they look at the results, form a hypothesis, and decide what to probe next. We wanted to build that feedback loop — an agent that **observes, thinks, and acts** — powered by AI.

That intersection of cybersecurity and autonomous AI agents is what inspired Sentinel.

## What It Does

Sentinel is a full-stack security platform that deploys a swarm of **10 specialized AI agents** against a target URL, each responsible for a different attack surface:

| Agent | Role |
|---|---|
| 🕷️ **Spider** | Crawls the site to map the full attack surface |
| 🔍 **Exposure** | Detects leaked secrets, API keys, and sensitive files |
| 🛡️ **Headers & TLS** | Audits HTTP security headers and TLS configuration |
| 🌐 **CORS** | Tests Cross-Origin Resource Sharing misconfigurations |
| 🔌 **Port Scan** | Probes open ports and services |
| 🔐 **Auth Abuse** | Tests authentication and authorization bypass |
| 💉 **SQLi** | Attempts SQL injection attacks |
| ⚡ **XSS** | Tests for Cross-Site Scripting vulnerabilities |
| 🤖 **Red Team** | LLM-powered autonomous pen tester with browser control |
| 🧠 **LLM Analysis** | AI-driven contextual analysis of discovered data |

The agents run concurrently on the backend, report findings to a shared database in real-time, and the results are synthesized into a **Gemini-powered remediation report** — complete with risk grades, code-level fix instructions, and OWASP references.

### The Risk Score

Each finding is weighted by severity, producing a composite risk score $S$ and letter grade:

$$S = \max\!\Big(0,\;\; 100 - \sum_{i=1}^{n} w(s_i)\Big)$$

where the weight function $w$ maps severity levels to penalty points:

$$w(s) = \begin{cases} 25 & \text{if } s = \texttt{CRITICAL} \\ 10 & \text{if } s = \texttt{HIGH} \\ 3 & \text{if } s = \texttt{MEDIUM} \\ 1 & \text{if } s = \texttt{LOW} \end{cases}$$

The letter grade is then:

$$\text{Grade} = \begin{cases} A & S \geq 90 \\ B & 75 \leq S < 90 \\ C & 50 \leq S < 75 \\ D & 25 \leq S < 50 \\ F & S < 25 \end{cases}$$

## How We Built It

### Architecture

Sentinel follows a **Control Plane / Execution Plane** split:

```
┌─────────────────────┐        ┌──────────────────────────────┐
│   Next.js Frontend  │◄──────►│  Flask API (Control Plane)   │
│   Real-time UI      │        │  • /runs/start               │
│   Agent Monitoring   │        │  • /runs/<id>/report         │
│   Report Viewer      │        │  • Gemini remediation engine │
└─────────────────────┘        └──────────┬───────────────────┘
                                          │ Supabase (Realtime)
                               ┌──────────▼───────────────────┐
                               │  Worker (Execution Plane)     │
                               │  • Polls for QUEUED runs      │
                               │  • Orchestrates agent swarm   │
                               │  • Phase 1: Spider (recon)    │
                               │  • Phase 2: Scanners (async)  │
                               │  • Phase 3: LLM agents (seq)  │
                               └───────────────────────────────┘
```

### Tech Stack

- **Frontend:** Next.js + TypeScript with a dark cyber-security aesthetic (glassmorphism, neon accents)
- **Backend:** Python + Flask for the REST API
- **Agents:** Python `asyncio` with Playwright for browser automation and `aiohttp` for HTTP probing
- **AI:** Google Gemini (`gemini-2.0-flash`) for the Red Team agent's reasoning loop and report generation
- **Database:** Supabase (PostgreSQL) with Realtime subscriptions for live agent event streaming
- **Deployment:** Render (single-process `multiprocessing` setup running both API + worker)

### The Agent Framework

Every agent extends a `BaseAgent` abstract class that provides:

- **Lifecycle management** — automatic `QUEUED → RUNNING → COMPLETED/FAILED` state transitions
- **Event emission** — structured events streamed to the frontend via Supabase Realtime
- **Finding reporting** — severity-tagged vulnerabilities with reproduction steps
- **Progress tracking** — percentage-based progress updates for the UI

The **Red Team agent** is the most complex — it's an autonomous AI loop that:

1. Launches a headless Chromium browser via Playwright
2. Performs deep passive reconnaissance (cookie analysis, JS source scanning, API endpoint discovery)
3. Enters an **observe → think → act** cycle powered by Gemini, deciding which tools to invoke (click, type, run JavaScript, make API requests, take screenshots)
4. Reports findings with full reproduction steps

### Worker Orchestration

The worker runs agents in **three phases** to balance thoroughness and rate limits:

1. **Phase 1 — Spider** runs first to map the attack surface
2. **Phase 2 — Scanner agents** (Exposure, Headers, CORS, Port Scan, SQLi, XSS, Auth Abuse) run concurrently via `asyncio.gather()`
3. **Phase 3 — LLM agents** (Red Team, LLM Analysis) run sequentially to avoid API rate limit contention

## Challenges We Faced

### 1. LLM Rate Limits vs. Agent Concurrency

Our first design ran all agents in parallel — including multiple LLM-powered ones. We immediately hit Gemini's requests-per-minute limits, causing agents to crash mid-scan. The fix was the **phased orchestration** model: fast scanner agents run concurrently, but LLM agents run one at a time.

### 2. Making the Red Team Agent Actually Useful

Early versions of the Red Team agent were essentially random clickers. Getting an LLM to systematically probe a website required careful prompt engineering: we had to teach it to **prioritize** (e.g., check for exposed `.env` files before fuzzing form inputs), **stay on-domain** (we added a domain guard to prevent it from navigating away), and **avoid infinite loops** (capping the observe-think-act cycle).

### 3. Keeping the UI in Sync

With 10+ agents running asynchronously and emitting events at different rates, keeping the frontend in sync was non-trivial. Supabase Realtime solved the *transport* problem, but we still had to design the event schema carefully — every event carries a `run_id`, `agent_type`, and structured `data` payload so the frontend can correctly route updates to the right agent lane.

### 4. False Positives

Automated scanners are notorious for false positives. Our initial XSS and SQLi agents would flag every reflected parameter as a vulnerability. We iterated on the detection heuristics, requiring agents to **verify** findings (e.g., confirming that injected JavaScript actually executes in the DOM) before reporting them — bringing the signal-to-noise ratio to an acceptable level.

### 5. Single-Process Deployment

Deploying on Render's free tier meant running both the Flask API and the async worker in a single process. We used Python's `multiprocessing` module to spawn the worker as a child process, with graceful shutdown handling. It's not elegant, but it works — and it means the entire backend runs from a single `python main.py` command.

## What We Learned

- **Agent design is prompt engineering + systems engineering.** The hardest part isn't calling the LLM API — it's designing the observation/action loop, managing state across async agents, and handling the dozen ways an agent can fail silently.
- **Phas

[README truncated for size]

## Detected evidence (automated analysis)

Indexed codebase: 45 recognized source files, 431 KB.
- CSS (language) — detected in the code
- Flask (technology) — detected in the code
- Next.js (technology) — detected in the code
- OpenAI (technology) — detected in the code
- Python (language) — detected in the code
- React (technology) — detected in the code
- SQL (language) — detected in the code
- Supabase (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code
- FastAPI (technology) — claimed on Devpost, not found in the code
- Google Gemini (technology) — claimed on Devpost, not found in the code
- AI coding agent: Claude Code — evidence: commit authorship or trailers

## Codebase structure (from repository index)

### Files (56 of 56)

```
.gitignore
backend/.env.example
backend/.gitignore
backend/agents/auth_abuse.py
backend/agents/base.py
backend/agents/cors.py
backend/agents/exposure_v2.py
backend/agents/exposure.py
backend/agents/headers_v2.py
backend/agents/headers.py
backend/agents/llm_analysis.py
backend/agents/portscan.py
backend/agents/red_team.py
backend/agents/spider.py
backend/agents/sqli.py
backend/agents/xss.py
backend/app.py
backend/db.py
backend/debug_db.py
backend/main.py
backend/migrate_add_configuration.py
backend/migrate_config.py
backend/migrate_db.py
backend/modal_agents.py
backend/Procfile
backend/README.md
backend/report_generator.py
backend/requirements.txt
backend/summary_generator.py
backend/test_agents.py
backend/test_finding_insertion.py
backend/worker.py
frontend/.gitignore
frontend/components.json
frontend/eslint.config.mjs
frontend/next.config.ts
frontend/package.json
frontend/postcss.config.mjs
frontend/README.md
frontend/src/app/globals.css
frontend/src/app/layout.tsx
frontend/src/app/page.tsx
frontend/src/app/runs/[id]/findings/[findingId]/page.tsx
frontend/src/app/runs/[id]/page.tsx
frontend/src/app/runs/[id]/report/page.tsx
frontend/src/components/AgentDetailModal.tsx
frontend/src/components/AgentLane.tsx
frontend/src/components/AgentStatusGrid.tsx
frontend/src/components/ReportModal.tsx
frontend/src/components/RunHeader.tsx
frontend/src/lib/supabase.ts
frontend/src/lib/types.ts
frontend/tsconfig.json
README.md
supabase/migrations/20240214_add_configuration.sql
supabase/schema.sql
```

### Dependencies

- backend/requirements.txt: aiohttp, asyncio, beautifulsoup4, flask, flask-cors, google-genai, gunicorn, modal, openai, playwright, pytest, python-dotenv, supabase
- frontend/package.json: @supabase/supabase-js@^2.95.3, @tailwindcss/postcss@^4, @types/node@^20, @types/react@^19, @types/react-dom@^19, clsx@^2.1.1, eslint@^9, eslint-config-next@16.1.6, framer-motion@^12.34.0, html2canvas@^1.4.1, jspdf@^4.1.0, lucide-react@^0.564.0, next@16.1.6, react@19.2.3, react-dom@19.2.3, react-markdown@^10.1.0, remark-gfm@^4.0.1, tailwind-merge@^3.4.0, tailwindcss@^4, typescript@^5

### Recent commits (newest first)

- modal integration
- build fixes
- Merge pull request #12 from EKasuti/noopenclaw
- readme
- Merge pull request #11 from EKasuti/noopenclaw
- Merge remote-tracking branch 'origin/main' into noopenclaw
- changed favicon icon
- report
- Merge pull request #10 from EKasuti/trial
- update
- delete: recording video feature
- im done
- revamp: agents logic and flow
- fix: merge conflicts
- Merge pull request #7 from EKasuti/enhance-scanners-with-playwright-4018293755889477936
- Enhance security agents with Playwright scanning and screenshots
- Merge pull request #6 from EKasuti/refactor-agents-high-signal-12488357524706416689
- Merge branch 'main' of https://github.com/EKasuti/sentinel into refactor-agents-high-signal-12488357524706416689
- Refactor security agents for high-signal reporting
- llm improvement

## Key source files (fetched from GitHub, selected and truncated for size)

### backend/requirements.txt

```
flask
flask-cors
supabase
python-dotenv
playwright
asyncio
aiohttp
openai
beautifulsoup4
pytest
gunicorn
modal
google-genai

```

### frontend/package.json

```
{
  "name": "frontend",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "eslint"
  },
  "dependencies": {
    "@supabase/supabase-js": "^2.95.3",
    "clsx": "^2.1.1",
    "framer-motion": "^12.34.0",
    "html2canvas": "^1.4.1",
    "jspdf": "^4.1.0",
    "lucide-react": "^0.564.0",
    "next": "16.1.6",
    "react": "19.2.3",
    "react-dom": "19.2.3",
    "react-markdown": "^10.1.0",
    "remark-gfm": "^4.0.1",
    "tailwind-merge": "^3.4.0"
  },
  "devDependencies": {
    "@tailwindcss/postcss": "^4",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "eslint": "^9",
    "eslint-config-next": "16.1.6",
    "tailwindcss": "^4",
    "typescript": "^5"
  }
}

```

### backend/main.py

```python
"""
Main entry point for Render deployment.
Runs both Flask API and background worker in the same process.
"""
import multiprocessing
import os
import asyncio
from app import app
from worker import worker_loop

def run_flask():
    """Run Flask API server"""
    port = int(os.getenv('PORT', 5000))
    app.run(host='0.0.0.0', port=port)

def run_worker():
    """Run background worker"""
    asyncio.run(worker_loop())

if __name__ == '__main__':
    # Start worker in a separate process
    worker_process = multiprocessing.Process(target=run_worker)
    worker_process.start()

    # Run Flask in main process
    try:
        run_flask()
    finally:
        worker_process.terminate()
        worker_process.join()

```

### backend/app.py

```python
from flask import Flask, request, jsonify
from flask_cors import CORS
from db import supabase
from google import genai
import uuid
import os
import json
import logging
from dotenv import load_dotenv

load_dotenv()

# Configure logging
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)

app = Flask(__name__)

# Production-ready CORS configuration
ALLOWED_ORIGINS = os.getenv('ALLOWED_ORIGINS', 'http://localhost:3000').split(',')
CORS(app, origins=ALLOWED_ORIGINS, supports_credentials=True)

# Security headers
@app.after_request
def add_security_headers(response):
    response.headers['X-Content-Type-Options'] = 'nosniff'
    response.headers['X-Frame-Options'] = 'DENY'
    response.headers['X-XSS-Protection'] = '1; mode=block'
    response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
    return response

# ---------- Gemini Client ----------
gemini_client = None
gemini_key = os.getenv("GEMINI_API_KEY")
if gemini_key:
    gemini_client = genai.Client(api_key=gemini_key)

@app.route('/health', methods=['GET'])
def health():
    logger.info("Health check endpoint called")
    return jsonify({"status": "ok"}), 200

@app.route('/runs/start', methods=['POST'])
def start_run():
    try:
        data = request.json
        if not data:
            return jsonify({"error": "Request body is required"}), 400

        target_url = data.get('target_url')
        agents = data.get('agents', ['spider', 'exposure', 'headers_tls', 'cors', 'portscan'])

        if not target_url:
            return jsonify({"error": "target_url is required"}), 400

        logger.info(f"Starting security run for {target_url} with agents: {agents}")

        # 1. Create Run (INITIALIZING to prevent worker race condition)
        run_data = {
            "target_url": target_url,
            "status": "INITIALIZING"
        }

        run_res = supabase.table('security_runs').insert(run_data).execute()
        if not run_res.data:
            raise Exception("Failed to create security run")

        run_id = run_res.data[0]['id']

        # 2. Create Agent Sessions
        sessions = []
        for agent in agents:
            sessions.append({
                "run_id": run_id,
                "agent_type": agent,
                "status": "QUEUED"
            })

        supabase.table('agent_sessions').insert(sessions).execute()

        # 3. Mark Run as QUEUED (Now worker can pick it up)
        supabase.table('security_runs').update({"status": "QUEUED"}).eq("id", run_id).execute()

        logger.info(f"Security run {run_id} created successfully")
        return jsonify({"run_id": run_id, "status": "QUEUED"}), 201

    except Exception as e:
        logger.error(f"Error starting security run: {str(e)}")
        return jsonify({"error": "Failed to start security run"}), 500

@app.route('/runs/<run_id>/cancel', methods=['POST'])
def cancel_run(run_id):
    try:
        logger.info(f"Cancelling security run {run_id}")

        # Cancel run
        supabase.table('security_runs').update({"status": "CANCELLED"}).eq("id", run_id).execute()
        # Cancel sessions
        supabase.table('agent_sessions').update({"status": "CANCELLED"}).eq("run_id", run_id).execute()

        logger.info(f"Security run {run_id} cancelled successfully")
        return jsonify({"status": "CANCELLED"}), 200
    except Exception as e:
        logger.error(f"Error cancelling security run {run_id}: {str(e)}")
        return jsonify({"error": "Failed to cancel security run"}), 500

# ---------- REPORT ENDPOINT — Gemini-powered remediation ----------

def _calculate_risk(findings):
    """Calculate risk score and grade from findings."""
    weights = {"CRITICAL": 25, "HIGH": 10, "MEDIUM": 3, "LOW": 1}
    total_points = sum(weights.get(f.get("severity", "LOW"), 1) for f in findings)
    score = max(0, 100 - total_points)
    if score >= 90: grade = "A"
    elif score >= 75: grade = "B"
    elif score >= 50: grade = "C"
    elif score >= 25: grade = "D"
    else: grade = "F"
    return score, grade

@app.route('/runs/<run_id>/report', methods=['GET'])
def get_report(run_id):
    try:
        # 1. Fetch run info
        run_res = supabase.table('security_runs').select('*').eq('id', run_id).single().execute()
        run = run_res.data

        # 2. Fetch findings
        find_res = supabase.table('findings').select('*').eq('run_id', run_id).order('created_at').execute()
        findings = find_res.data or []

        # 3. Fetch sessions
        sess_res = supabase.table('agent_sessions').select('*').eq('run_id', run_id).execute()
        sessions = sess_res.data or []

        # 3b. Fetch reproduction step events linked to findings
        repro_res = supabase.table('run_events').select('data').eq('run_id', run_id).eq('event_type', 'REPRO_STEPS').execute()
        repro_map = {}  # finding_id -> list of steps
        for ev in (repro_res.data or []):
            data = ev.get("data", {})
            fid = data.get("finding_id")
            steps = data.get("steps", [])
            if fid and steps:
                repro_map[fid] = steps

        # 4. Calculate risk
        score, grade = _calculate_risk(findings)

        # 5. Gemini remediation — extensive, detailed reports
        remediation_map = {}
        if gemini_client and findings:
            findings_text = "\n".join([
                f"- [{f['severity']}] {f['title']}\n  Evidence: {f.get('evidence', 'N/A')[:300]}\n  Basic recommendation: {f.get('recommendation', 'N/A')[:200]}"
                for f in findings
            ])

            prompt = f"""You are an elite blue hat security consultant writing a professional penetration test report for a client.

TARGET APPLICATION: {run.get('target_url', 'Unknown')}

The following vulnerabilities were discovered during an authorized security assessment:

{findings_text}

For EACH finding, write a comprehensive remediat
[truncated — 4472 more characters]
```

### frontend/src/app/layout.tsx

```typescript
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

export const metadata: Metadata = {
  title: "Sentinel | Autonomous Security Intelligence",
  description: "Deploy 10 AI agents to autonomously discover vulnerabilities in any web application. Spider crawls, fuzz inputs, probe APIs, and chain exploits — all in real-time.",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        {children}
      </body>
    </html>
  );
}

```

### frontend/src/app/page.tsx

```typescript
"use client";

import { useState, useEffect, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useRouter } from "next/navigation";
import {
  Shield,
  Play,
  Loader2,
  Zap,
  Target,
  Globe,
  Lock,
  Bug,
  Cpu,
  Search,
  Radio,
  Radar,
  ChevronRight,
  Eye,
  Key,
} from "lucide-react";

// Animated background particle field
function ParticleField() {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    let animationId: number;
    const particles: {
      x: number;
      y: number;
      vx: number;
      vy: number;
      size: number;
      opacity: number;
      color: string;
    }[] = [];
    const colors = ["#00f0ff", "#bd00ff", "#00ff9f", "#ff003c"];

    const resize = () => {
      canvas.width = window.innerWidth;
      canvas.height = window.innerHeight;
    };
    resize();
    window.addEventListener("resize", resize);

    for (let i = 0; i < 80; i++) {
      particles.push({
        x: Math.random() * canvas.width,
        y: Math.random() * canvas.height,
        vx: (Math.random() - 0.5) * 0.5,
        vy: (Math.random() - 0.5) * 0.5,
        size: Math.random() * 2 + 0.5,
        opacity: Math.random() * 0.5 + 0.1,
        color: colors[Math.floor(Math.random() * colors.length)],
      });
    }

    const animate = () => {
      ctx.fillStyle = "rgba(0, 0, 0, 0.05)";
      ctx.fillRect(0, 0, canvas.width, canvas.height);

      particles.forEach((p, i) => {
        p.x += p.vx;
        p.y += p.vy;
        if (p.x < 0 || p.x > canvas.width) p.vx *= -1;
        if (p.y < 0 || p.y > canvas.height) p.vy *= -1;

        ctx.beginPath();
        ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
        ctx.fillStyle = p.color;
        ctx.globalAlpha = p.opacity;
        ctx.fill();

        particles.forEach((p2, j) => {
          if (i >= j) return;
          const dx = p.x - p2.x;
          const dy = p.y - p2.y;
          const dist = Math.sqrt(dx * dx + dy * dy);
          if (dist < 120) {
            ctx.beginPath();
            ctx.moveTo(p.x, p.y);
            ctx.lineTo(p2.x, p2.y);
            ctx.strokeStyle = p.color;
            ctx.globalAlpha = (1 - dist / 120) * 0.15;
            ctx.lineWidth = 0.5;
            ctx.stroke();
          }
        });
      });

      ctx.globalAlpha = 1;
      animationId = requestAnimationFrame(animate);
    };

    animate();
    return () => {
      cancelAnimationFrame(animationId);
      window.removeEventListener("resize", resize);
    };
  }, []);

  return (
    <canvas
      ref={canvasRef}
      className="fixed inset-0 pointer-events-none z-0"
      style={{ background: "transparent" }}
    />
  );
}

function RadarScan() {
  return (
    <div className="relative w-40 h-40">
      <div className="absolute inset-0 rounded-full border border-cyber-blue/20" />
      <div className="absolute inset-4 rounded-full border border-cyber-blue/15" />
      <div className="absolute inset-8 rounded-full border border-cyber-blue/10" />
      <div className="absolute inset-12 rounded-full border border-cyber-blue/5" />
      <motion.div
        className="absolute inset-0 rounded-full"
        style={{
          background:
            "conic-gradient(from 0deg, transparent 0deg, rgba(0, 240, 255, 0.3) 30deg, transparent 60deg)",
        }}
        animate={{ rotate: 360 }}
        transition={{ duration: 3, repeat: Infinity, ease: "linear" }}
      />
      <div className="absolute inset-0 flex items-center justify-center">
        <Radar className="w-6 h-6 text-cyber-blue" />
      </div>
      <motion.div
        className="absolute w-1.5 h-1.5 rounded-full bg-danger-red"
        style={{ top: "25%", left: "60%" }}
        animate={{ opacity: [0, 1, 0] }}
        transition={{ duration: 2, repeat: Infinity, delay: 0.5 }}
      />
      <motion.div
        className="absolute w-1.5 h-1.5 rounded-full bg-yellow-400"
        style={{ top: "65%", left: "30%" }}
        animate={{ opacity: [0, 1, 0] }}
        transition={{ duration: 2, repeat: Infinity, delay: 1.2 }}
      />
      <motion.div
        className="absolute w-1.5 h-1.5 rounded-full bg-cyber-blue"
        style={{ top: "45%", left: "75%" }}
        animate={{ opacity: [0, 1, 0] }}
        transition={{ duration: 2, repeat: Infinity, delay: 0.8 }}
      />
    </div>
  );
}

function AgentCard({
  icon: Icon,
  name,
  description,
  color,
  delay,
}: {
  icon: React.ElementType;
  name: string;
  description: string;
  color: string;
  delay: number;
}) {
  return (
    <motion.div
      initial={{ opacity: 0, y: 20 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ delay, duration: 0.5 }}
      className="group relative p-4 rounded-xl bg-gray-900/40 border border-gray-800/50 hover:border-gray-700 transition-all duration-300 backdrop-blur-sm"
    >
      <div className="flex items-start gap-3">
        <div className={`p-2 rounded-lg bg-gradient-to-br ${color} bg-opacity-10 shrink-0`}>
          <Icon className="w-4 h-4 text-white" />
        </div>
        <div>
          <h3 className="text-sm font-semibold text-white mb-1">{name}</h3>
          <p className="text-xs text-gray-500 leading-relaxed">{description}</p>
        </div>
      </div>
    </motion.div>
  );
}

function TerminalDemo() {
  const [lines, setLines] = useState<string[]>([]);
  const terminalLines = [
    "$ sentinel scan https://target-app.com",
    "[SPIDER] Crawling attack surface...",
    "[SPIDER] Found 47 URLs, 12 forms, 8 API endpoints",
    "[HEADERS] Missing CSP, HSTS misconfigured",
    "[EXPOSURE] CRITICAL: Supabase anon key in JS bundle",
    "[CORS] Origin reflection + credentials on /api",
    "[SQLI] Testing login form with 17 payloads...",
    "[SQLI] Auth bypass: ' OR 1=1-- on /login",
    "[RED_TEAM] RLS disabled on 'users' table",
    "[RED_TEAM] 3 tables publicly readable",

[truncated — 13314 more characters]
```

### frontend/src/app/runs/[id]/page.tsx

```typescript
"use client";

import { useEffect, useState, useRef } from "react";
import { useParams } from "next/navigation";
import { supabase } from "@/lib/supabase";
import { AgentSession, RunEvent, Finding, SecurityRun } from "@/lib/types";
import AgentLane from "@/components/AgentLane";
import {
    Shield, Activity, Bug, FileText, Clock, Globe, AlertTriangle,
    CheckCircle2, Loader2, ArrowLeft, Download, ChevronDown,
} from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import Link from "next/link";

function SeverityRing({ criticals, highs, mediums, lows }: { criticals: number; highs: number; mediums: number; lows: number }) {
    const total = criticals + highs + mediums + lows;
    if (total === 0) return null;
    const radius = 36;
    const circumference = 2 * Math.PI * radius;
    const segments = [
        { count: criticals, color: "#ef4444" },
        { count: highs, color: "#f97316" },
        { count: mediums, color: "#eab308" },
        { count: lows, color: "#3b82f6" },
    ];
    let offset = 0;

    return (
        <div className="relative w-24 h-24 flex items-center justify-center">
            <svg className="w-24 h-24 -rotate-90" viewBox="0 0 80 80">
                <circle cx="40" cy="40" r={radius} fill="none" stroke="#1f2937" strokeWidth="6" />
                {segments.map((seg, i) => {
                    const len = (seg.count / total) * circumference;
                    const el = (
                        <circle
                            key={i}
                            cx="40" cy="40" r={radius}
                            fill="none" stroke={seg.color}
                            strokeWidth="6" strokeLinecap="round"
                            strokeDasharray={`${len} ${circumference - len}`}
                            strokeDashoffset={-offset}
                            className="transition-all duration-1000"
                        />
                    );
                    offset += len;
                    return el;
                })}
            </svg>
            <div className="absolute inset-0 flex flex-col items-center justify-center">
                <span className="text-2xl font-black text-white">{total}</span>
                <span className="text-[9px] font-mono text-gray-500">VULNS</span>
            </div>
        </div>
    );
}

function RiskScore({ findings }: { findings: Finding[] }) {
    const weights: Record<string, number> = { CRITICAL: 10, HIGH: 5, MEDIUM: 2, LOW: 0.5, INFO: 0 };
    const raw = findings.reduce((s, f) => s + (weights[f.severity] || 0), 0);
    const score = Math.min(10, raw / 3).toFixed(1);
    const num = parseFloat(score);
    const color = num >= 8 ? "text-red-500" : num >= 5 ? "text-orange-400" : num >= 3 ? "text-yellow-400" : "text-success-green";

    return (
        <div className="text-center">
            <div className={`text-3xl font-black ${color}`}>{score}</div>
            <div className="text-[9px] font-mono text-gray-500">RISK / 10</div>
        </div>
    );
}

export default function RunDetails() {
    const params = useParams();
    const runId = params.id as string;
    const eventsEndRef = useRef<HTMLDivElement>(null);

    const [run, setRun] = useState<SecurityRun | null>(null);
    const [sessions, setSessions] = useState<AgentSession[]>([]);
    const [events, setEvents] = useState<RunEvent[]>([]);
    const [findings, setFindings] = useState<Finding[]>([]);
    const [eventFilter, setEventFilter] = useState<string | null>(null);

    useEffect(() => {
        if (!runId) return;

        const fetchData = async () => {
            const [runRes, sessRes, eventRes, findRes] = await Promise.all([
                supabase.from("security_runs").select("*").eq("id", runId).single(),
                supabase.from("agent_sessions").select("*").eq("run_id", runId),
                supabase.from("run_events").select("*").eq("run_id", runId).order("created_at", { ascending: true }).limit(200),
                supabase.from("findings").select("*").eq("run_id", runId),
            ]);
            if (runRes.data) setRun(runRes.data);
            if (sessRes.data) setSessions(sessRes.data);
            if (eventRes.data) setEvents(eventRes.data);
            if (findRes.data) setFindings(findRes.data);
        };
        fetchData();

        const channel = supabase
            .channel(`run:${runId}`)
            .on("postgres_changes", { event: "*", schema: "public", table: "security_runs", filter: `id=eq.${runId}` },
                (payload) => { if (payload.new) setRun(payload.new as SecurityRun); })
            .on("postgres_changes", { event: "*", schema: "public", table: "agent_sessions", filter: `run_id=eq.${runId}` },
                (payload) => {
                    const ns = payload.new as AgentSession;
                    setSessions((prev) => {
                        const idx = prev.findIndex((s) => s.id === ns.id);
                        if (idx === -1) return [...prev, ns];
                        const u = [...prev]; u[idx] = ns; return u;
                    });
                })
            .on("postgres_changes", { event: "INSERT", schema: "public", table: "run_events", filter: `run_id=eq.${runId}` },
                (payload) => { setEvents((prev) => [...prev, payload.new as RunEvent].slice(-200)); })
            .on("postgres_changes", { event: "INSERT", schema: "public", table: "findings", filter: `run_id=eq.${runId}` },
                (payload) => { setFindings((prev) => [...prev, payload.new as Finding]); })
            .subscribe();

        const interval = setInterval(fetchData, 3000);
        return () => { supabase.removeChannel(channel); clearInterval(interval); };
    }, [runId]);

    useEffect(() => {
        eventsEndRef.current?.scrollIntoView({ behavior: "smooth" });
    }, [events.length]);

    const criticals = findings.filter((f) => f.severity === "CRITICAL").length;
    const highs = findings.filter((f) => f.severity
[truncated — 16866 more characters]
```

### frontend/src/app/runs/[id]/findings/[findingId]/page.tsx

```typescript
"use client";

import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { supabase } from "@/lib/supabase";
import { Finding } from "@/lib/types";
import { Shield, ArrowLeft, AlertTriangle, Film, Camera, Maximize2, X } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";

export default function FindingDetails() {
    const params = useParams();
    const router = useRouter();
    const runId = params.id as string;
    const findingId = params.findingId as string;

    const [finding, setFinding] = useState<Finding | null>(null);
    const [selectedScreenshot, setSelectedScreenshot] = useState<string | null>(null);

    useEffect(() => {
        const fetchFinding = async () => {
            const { data } = await supabase
                .from('findings')
                .select('*')
                .eq('id', findingId)
                .single();
            if (data) setFinding(data);
        };
        fetchFinding();
    }, [findingId]);

    if (!finding) return <div className="p-10 text-white font-mono">Loading finding...</div>;

    const severityColor =
        finding.severity === 'CRITICAL' ? 'text-red-500 border-red-500' :
            finding.severity === 'HIGH' ? 'text-orange-500 border-orange-500' :
                finding.severity === 'MEDIUM' ? 'text-yellow-500 border-yellow-500' : 'text-blue-500 border-blue-500';

    return (
        <div className="min-h-screen bg-black text-white p-8 font-mono">
            {/* Image Modal for Evidence Trail */}
            <AnimatePresence>
                {selectedScreenshot && (
                    <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/90 backdrop-blur-sm" onClick={() => setSelectedScreenshot(null)}>
                        <motion.img
                            initial={{ scale: 0.9, opacity: 0 }}
                            animate={{ scale: 1, opacity: 1 }}
                            exit={{ scale: 0.9, opacity: 0 }}
                            src={selectedScreenshot}
                            alt="Evidence Detail"
                            className="max-w-full max-h-screen rounded border border-cyber-blue shadow-2xl"
                        />
                        <button className="absolute top-4 right-4 text-white hover:text-cyber-blue" onClick={() => setSelectedScreenshot(null)}>
                            <X size={32} />
                        </button>
                    </div>
                )}
            </AnimatePresence>

            <header className="mb-8 border-b border-gray-800 pb-4">
                <button
                    onClick={() => router.back()}
                    className="text-gray-400 hover:text-white flex items-center gap-2 mb-4"
                >
                    <ArrowLeft size={16} /> Back to Run
                </button>
                <div className="flex justify-between items-start">
                    <div>
                        <div className="flex items-center gap-3 mb-2">
                            <h1 className="text-3xl font-bold">{finding.title}</h1>
                            {finding.screenshots && finding.screenshots.length > 0 && (
                                <span className="flex items-center gap-1 bg-red-900/40 border border-red-500/50 text-red-200 text-[10px] font-bold px-2 py-0.5 rounded tracking-wider animate-pulse">
                                    <Camera size={12} /> PROOF OF EXPLOITATION
                                </span>
                            )}
                        </div>
                        <div className="flex gap-4 items-center">
                            <span className={`border px-2 py-1 text-xs font-bold rounded ${severityColor}`}>
                                {finding.severity}
                            </span>
                            <span className="text-gray-500 text-sm">Agent: {finding.agent_type}</span>
                            <span className="text-gray-500 text-sm">ID: {finding.id}</span>
                        </div>
                    </div>
                    <Shield className="w-12 h-12 text-gray-800" />
                </div>
            </header>

            {/* Evidence Trail Filmstrip */}
            {finding.screenshots && finding.screenshots.length > 0 && (
                <motion.div
                    initial={{ x: 50, opacity: 0 }}
                    animate={{ x: 0, opacity: 1 }}
                    transition={{ duration: 0.5 }}
                    className="mb-8"
                >
                    <h3 className="text-cyber-blue text-sm font-bold tracking-widest mb-4 flex items-center gap-2 border-b border-gray-800 pb-2">
                        <Film size={16} /> AGENT EVIDENCE TRAIL
                    </h3>
                    <div className="flex gap-4 overflow-x-auto pb-4 scrollbar-thin scrollbar-thumb-gray-700">
                        {finding.screenshots.map((shot, idx) => (
                            <motion.div
                                key={idx}
                                initial={{ opacity: 0, x: 20 }}
                                animate={{ opacity: 1, x: 0 }}
                                transition={{ delay: idx * 0.08 }}
                                className="flex-shrink-0 w-64 group cursor-pointer"
                                onClick={() => setSelectedScreenshot(shot.url)}
                            >
                                <div className="relative aspect-video rounded border border-gray-700 overflow-hidden group-hover:border-cyber-blue/50 transition-colors">
                                    <img src={shot.url} alt={shot.caption} className="object-cover w-full h-full" />
                                    <div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
                                        <Maximize2 className="t
[truncated — 3401 more characters]
```

### frontend/src/app/runs/[id]/report/page.tsx

```typescript
"use client";

import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import {
    Shield,
    AlertTriangle,
    CheckCircle,
    ChevronDown,
    ChevronUp,
    ArrowLeft,
    Bot,
    Sparkles,
    Clock,
    Zap,
    Download,
    ExternalLink,
    Info,
    AlertCircle,
    BookOpen,
    Wrench,
    Terminal,
} from "lucide-react";

// ---------- Types ----------
interface ReportFinding {
    id: string;
    severity: "CRITICAL" | "HIGH" | "MEDIUM" | "LOW";
    title: string;
    evidence: string;
    recommendation: string;
    agent_type: string;
    created_at: string;
    what_is_wrong: string;
    why_it_matters: string;
    how_to_fix: string;
    references: string[];
    priority: string;
    effort: string;
    repro_steps: { command: string; output: string }[];
}

interface ReportSession {
    agent_type: string;
    status: string;
    progress: number;
}

interface ReportData {
    run: {
        id: string;
        target_url: string;
        status: string;
        created_at: string;
        ended_at: string;
    };
    risk_score: number;
    risk_grade: string;
    findings: ReportFinding[];
    sessions: ReportSession[];
    summary: {
        total: number;
        critical: number;
        high: number;
        medium: number;
        low: number;
    };
}

// ---------- Config ----------
const SEVERITY_CONFIG = {
    CRITICAL: {
        color: "text-red-400",
        bg: "bg-red-500/10",
        border: "border-red-500/40",
        badge: "bg-red-500/20 text-red-400 border-red-500/30",
        glow: "shadow-[0_0_15px_rgba(239,68,68,0.15)]",
        label: "Critical",
        printColor: "#f87171",
    },
    HIGH: {
        color: "text-orange-400",
        bg: "bg-orange-500/10",
        border: "border-orange-500/40",
        badge: "bg-orange-500/20 text-orange-400 border-orange-500/30",
        glow: "shadow-[0_0_15px_rgba(249,115,22,0.15)]",
        label: "High",
        printColor: "#fb923c",
    },
    MEDIUM: {
        color: "text-yellow-400",
        bg: "bg-yellow-500/10",
        border: "border-yellow-500/40",
        badge: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
        glow: "shadow-[0_0_15px_rgba(234,179,8,0.15)]",
        label: "Medium",
        printColor: "#facc15",
    },
    LOW: {
        color: "text-blue-400",
        bg: "bg-blue-500/10",
        border: "border-blue-500/40",
        badge: "bg-blue-500/20 text-blue-400 border-blue-500/30",
        glow: "shadow-[0_0_15px_rgba(59,130,246,0.15)]",
        label: "Low",
        printColor: "#60a5fa",
    },
};

const GRADE_COLORS: Record<string, string> = {
    A: "text-emerald-400", B: "text-green-400", C: "text-yellow-400",
    D: "text-orange-400", F: "text-red-400",
};

const GRADE_BG: Record<string, string> = {
    A: "from-emerald-500/20 to-emerald-500/5", B: "from-green-500/20 to-green-500/5",
    C: "from-yellow-500/20 to-yellow-500/5", D: "from-orange-500/20 to-orange-500/5",
    F: "from-red-500/20 to-red-500/5",
};

const AGENT_LABELS: Record<string, string> = {
    spider: "🕷️ Spider / Recon", exposure: "🔍 Secret Scanner", headers_tls: "🔒 Headers & TLS",
    cors: "🌐 CORS Scanner", portscan: "📡 Port Scanner",
    auth_abuse: "🔑 Auth Abuse", llm_analysis: "🧠 LLM Analysis",
    sqli: "💉 SQL Injection", xss: "⚡ XSS", red_team: "🔴 Red Team AI",
};

function formatDate(dateStr: string) {
    if (!dateStr) return "—";
    return new Date(dateStr).toLocaleString();
}

// ---------- Components ----------

function RiskGauge({ score, grade }: { score: number; grade: string }) {
    const circumference = 2 * Math.PI * 60;
    const offset = circumference - (score / 100) * circumference;
    return (
        <div className="relative w-44 h-44 mx-auto">
            <svg className="w-44 h-44 -rotate-90" viewBox="0 0 140 140">
                <circle cx="70" cy="70" r="60" stroke="rgba(255,255,255,0.05)" strokeWidth="8" fill="none" />
                <motion.circle cx="70" cy="70" r="60" stroke="currentColor" strokeWidth="8" fill="none" strokeLinecap="round"
                    className={GRADE_COLORS[grade] || "text-gray-400"}
                    initial={{ strokeDashoffset: circumference }}
                    animate={{ strokeDashoffset: offset }}
                    transition={{ duration: 1.5, ease: "easeOut" }}
                    strokeDasharray={circumference}
                />
            </svg>
            <div className="absolute inset-0 flex flex-col items-center justify-center">
                <motion.span className={`text-5xl font-black ${GRADE_COLORS[grade]}`}
                    initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ delay: 0.5, type: "spring" }}>
                    {grade}
                </motion.span>
                <span className="text-xs text-gray-500 font-mono mt-1">{score}/100</span>
            </div>
        </div>
    );
}

function CodeBlock({ code }: { code: string }) {
    // Parse code blocks from markdown-style formatting
    const parts = code.split(/(```[\s\S]*?```)/g);
    return (
        <div className="space-y-3">
            {parts.map((part, i) => {
                if (part.startsWith("```")) {
                    const lines = part.split("\n");
                    const lang = lines[0].replace("```", "").trim();
                    const codeContent = lines.slice(1, -1).join("\n");
                    return (
                        <div key={i} className="rounded-lg overflow-hidden">
                            {lang && (
                                <div className="bg-gray-800 text-gray-400 text-[10px] font-mono px-3 py-1 uppercase tracking-wider">
                                    {lang}
                                </div>
                            )}
                            <pre className="bg-gray-950 text-gray-200 text-sm p-4 overflow-x-auto font-mono leading-relaxed">
        
[truncated — 33866 more characters]
```

### frontend/next.config.ts

```typescript
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  /* config options here */
};

export default nextConfig;

```

[34 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]