# Report Viewer (`agentsec serve`) Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add `agentsec serve <output-dir>` — a Flask web server that renders `agentsec evaluate` / `agentsec multi-evaluate` artifacts as an interactive HTML dashboard with score cards, filterable findings table, and Markdown report rendering.

**Architecture:** `src/agentsec/serve/reader.py` contains pure I/O helpers (no Flask); `src/agentsec/serve/app.py` is a Flask app factory with 6 routes and Jinja2 templates; the CLI `serve` command in `cli.py` uses a lazy Flask import so Flask is never loaded on unrelated commands. Auto-detection at startup distinguishes single-target (`findings.json` at root) from multi-target (subdirectory layout produced by `agentsec multi-evaluate`).

**Tech Stack:** Flask 3.x, Jinja2 (bundled with Flask), Python `markdown` library, Bootstrap 5.3 CDN (no build step), vanilla JS for client-side filtering.

---

## File Map

| File | Role |
|------|------|
| `src/agentsec/serve/__init__.py` | Empty package marker |
| `src/agentsec/serve/reader.py` | Pure I/O: detect mode, load findings, parse scores, render markdown |
| `src/agentsec/serve/app.py` | Flask app factory + all 6 routes |
| `src/agentsec/serve/templates/base.html` | Bootstrap 5.3 layout, nav |
| `src/agentsec/serve/templates/dashboard.html` | Multi-target score table |
| `src/agentsec/serve/templates/target.html` | Score card + findings table + JS filter |
| `src/agentsec/serve/templates/report.html` | Rendered Markdown page |
| `src/agentsec/cli.py` | Add `serve` command (lazy import) |
| `pyproject.toml` | Add `flask>=3.0`, `markdown>=3.6` |
| `tests/serve/__init__.py` | Empty package marker |
| `tests/serve/test_reader.py` | Unit tests for reader.py |
| `tests/serve/test_app.py` | Flask test client integration tests |
| `CHANGELOG.md` | v0.7.0 entry |
| `ROADMAP.md` | Tick Web UI item |

---

## Task 1: Dependencies and `reader.py`

**Files:**
- Modify: `pyproject.toml`
- Create: `src/agentsec/serve/__init__.py`
- Create: `src/agentsec/serve/reader.py`
- Create: `tests/serve/__init__.py`
- Create: `tests/serve/test_reader.py`

- [ ] **Step 1: Add dependencies to pyproject.toml**

In `pyproject.toml`, in the `dependencies = [` list, add after `"pyyaml>=6.0",`:

```toml
"flask>=3.0",
"markdown>=3.6",
```

Install:
```bash
.venv/bin/pip install -e ".[dev]"
```
Expected: output includes `Successfully installed flask-3.x.x markdown-3.x.x`

- [ ] **Step 2: Write failing tests**

Create `tests/serve/__init__.py` — empty file.

Create `tests/serve/test_reader.py`:

```python
"""Unit tests for agentsec.serve.reader."""
from __future__ import annotations

from pathlib import Path

from agentsec.serve.reader import (
    TargetSummary,
    detect_mode,
    list_targets,
    load_findings_raw,
    parse_scores,
    render_markdown_file,
)

SAMPLE_DIR = Path("docs/samples/report-2026-04-28")


def test_detect_mode_single(tmp_path):
    (tmp_path / "findings.json").write_text("[]")
    assert detect_mode(tmp_path) == "single"


def test_detect_mode_multi(tmp_path):
    for name in ("prod", "staging"):
        (tmp_path / name).mkdir()
        (tmp_path / name / "findings.json").write_text("[]")
    assert detect_mode(tmp_path) == "multi"


def test_detect_mode_none(tmp_path):
    assert detect_mode(tmp_path) is None


def test_detect_mode_nonexistent(tmp_path):
    assert detect_mode(tmp_path / "ghost") is None


def test_list_targets_single():
    result = list_targets(SAMPLE_DIR, "single")
    assert len(result) == 1
    t = result[0]
    assert t.name == "report-2026-04-28"
    assert t.has_findings is True
    assert t.grade == "C"
    assert t.combined_score is not None


def test_list_targets_multi(tmp_path):
    for name in ("prod", "staging"):
        d = tmp_path / name
        d.mkdir()
        (d / "findings.json").write_text("[]")
    result = list_targets(tmp_path, "multi")
    assert {t.name for t in result} == {"prod", "staging"}


def test_list_targets_multi_partial(tmp_path):
    """Subdirectory without findings.json → has_findings=False."""
    (tmp_path / "ok").mkdir()
    (tmp_path / "ok" / "findings.json").write_text("[]")
    (tmp_path / "err").mkdir()
    result = list_targets(tmp_path, "multi")
    by_name = {t.name: t for t in result}
    assert by_name["ok"].has_findings is True
    assert by_name["err"].has_findings is False


def test_load_findings_raw_valid():
    findings = load_findings_raw(SAMPLE_DIR)
    assert len(findings) == 2
    assert findings[0]["check"] == "native_audit"


def test_load_findings_raw_missing(tmp_path):
    assert load_findings_raw(tmp_path) == []


def test_parse_scores_sample():
    scores = parse_scores(SAMPLE_DIR)
    assert scores is not None
    assert scores.grade == "C"
    assert scores.combined_score is not None
    assert abs(scores.combined_score - 62.17) < 0.1


def test_parse_scores_missing(tmp_path):
    assert parse_scores(tmp_path) is None


def test_render_markdown_file_valid():
    html = render_markdown_file(SAMPLE_DIR, "combined-report.md")
    assert html is not None
    assert "<h1" in html


def test_render_markdown_file_missing(tmp_path):
    assert render_markdown_file(tmp_path, "nonexistent.md") is None
```

- [ ] **Step 3: Run to verify tests fail**

```bash
.venv/bin/pytest tests/serve/test_reader.py -v
```
Expected: FAIL with `ModuleNotFoundError: No module named 'agentsec.serve'`

- [ ] **Step 4: Implement reader.py**

Create `src/agentsec/serve/__init__.py` — empty file.

Create `src/agentsec/serve/reader.py`:

```python
"""Pure I/O helpers for reading agentsec evaluate output directories."""
from __future__ import annotations

import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any

import markdown as _md

from agentsec.diff.score_delta import ScoreMeta

_META_RE = re.compile(r"<!--\s*meta:(\w+)\s+value:([^ ]+)\s*-->")
_COVERAGE_VERDICT_RE = re.compile(r"verdict_coverage=(\d+\.\d+)%")
_COVERAGE_ERROR_RE = re.compile(r"error_rate=(\d+\.\d+)%")
_REQUIRED_META = frozenset({"remote_score", "server_score", "combined_score", "grade"})


@dataclass
class TargetSummary:
    name: str
    output_dir: Path
    grade: str
    combined_score: float | None
    remote_score: float | None
    server_score: float | None
    critical_count: int
    high_count: int
    has_findings: bool


def detect_mode(base: Path) -> str | None:
    """Return 'single', 'multi', or None if no findings.json found."""
    try:
        if (base / "findings.json").exists():
            return "single"
        subdirs = [d for d in base.iterdir() if d.is_dir()]
        if any((d / "findings.json").exists() for d in subdirs):
            return "multi"
    except OSError:
        pass
    return None


def list_targets(base: Path, mode: str) -> list[TargetSummary]:
    if mode == "single":
        return [_summarise(base, base.name)]
    dirs = sorted(d for d in base.iterdir() if d.is_dir())
    return [_summarise(d, d.name) for d in dirs]


def _summarise(target_dir: Path, name: str) -> TargetSummary:
    has_findings = (target_dir / "findings.json").exists()
    raw = load_findings_raw(target_dir)
    critical = sum(1 for f in raw if f.get("severity") == "critical")
    high = sum(1 for f in raw if f.get("severity") == "high")
    scores = parse_scores(target_dir)
    return TargetSummary(
        name=name,
        output_dir=target_dir,
        grade=scores.grade if scores else "UNKNOWN",
        combined_score=scores.combined_score if scores else None,
        remote_score=scores.remote_score if scores else None,
        server_score=scores.server_score if scores else None,
        critical_count=critical,
        high_count=high,
        has_findings=has_findings,
    )


def load_findings_raw(target_dir: Path) -> list[dict[str, Any]]:
    path = target_dir / "findings.json"
    if not path.exists():
        return []
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
        return data if isinstance(data, list) else []
    except (json.JSONDecodeError, OSError):
        return []


def parse_scores(target_dir: Path) -> ScoreMeta | None:
    path = target_dir / "combined-report.md"
    if not path.exists():
        return None
    text = path.read_text(encoding="utf-8")
    found: dict[str, str] = {m.group(1): m.group(2) for m in _META_RE.finditer(text)}
    if not _REQUIRED_META.issubset(found):
        return None
    try:
        def _f(v: str) -> float | None:
            return None if v == "None" else float(v)
        remote = _f(found["remote_score"])
        server = _f(found["server_score"])
        combined = _f(found["combined_score"])
    except ValueError:
        return None
    vc_m = _COVERAGE_VERDICT_RE.search(text)
    er_m = _COVERAGE_ERROR_RE.search(text)
    return ScoreMeta(
        remote_score=remote,
        server_score=server,
        combined_score=combined,
        grade=found["grade"],
        verdict_coverage=float(vc_m.group(1)) / 100 if vc_m else None,
        error_rate=float(er_m.group(1)) / 100 if er_m else None,
    )


def render_markdown_file(target_dir: Path, filename: str) -> str | None:
    path = target_dir / filename
    if not path.exists():
        return None
    return _md.markdown(path.read_text(encoding="utf-8"), extensions=["tables"])
```

- [ ] **Step 5: Run tests to verify they pass**

```bash
.venv/bin/pytest tests/serve/test_reader.py -v
```
Expected: 13 passed, 0 failed.

- [ ] **Step 6: Commit**

```bash
git add pyproject.toml src/agentsec/serve/__init__.py src/agentsec/serve/reader.py tests/serve/__init__.py tests/serve/test_reader.py
git commit -m "feat(serve): add reader.py with pure I/O helpers; add flask/markdown deps"
```

---

## Task 2: Flask app and templates

**Files:**
- Create: `src/agentsec/serve/app.py`
- Create: `src/agentsec/serve/templates/base.html`
- Create: `src/agentsec/serve/templates/dashboard.html`
- Create: `src/agentsec/serve/templates/target.html`
- Create: `src/agentsec/serve/templates/report.html`
- Create: `tests/serve/test_app.py`

- [ ] **Step 1: Write failing tests**

Create `tests/serve/test_app.py`:

```python
"""Flask test client tests for agentsec.serve.app."""
from __future__ import annotations

import json
from pathlib import Path

import pytest

from agentsec.serve.app import create_app

SAMPLE_DIR = Path("docs/samples/report-2026-04-28")


@pytest.fixture()
def client():
    app = create_app(SAMPLE_DIR)
    app.config["TESTING"] = True
    return app.test_client()


def test_root_redirects_single(client):
    resp = client.get("/")
    assert resp.status_code == 302
    assert "/target/" in resp.headers["Location"]


def test_target_page_200(client):
    resp = client.get("/target/report-2026-04-28")
    assert resp.status_code == 200
    assert b"Grade" in resp.data or b"grade" in resp.data.lower()


def test_target_page_404(client):
    resp = client.get("/target/nonexistent")
    assert resp.status_code == 404


def test_report_page_200(client):
    resp = client.get("/target/report-2026-04-28/report/combined-report.md")
    assert resp.status_code == 200
    assert b"<h1" in resp.data


def test_report_page_unknown_file_blocked(client):
    resp = client.get("/target/report-2026-04-28/report/evil.txt")
    assert resp.status_code == 400


def test_report_page_missing_file_404(client):
    resp = client.get("/target/report-2026-04-28/report/remote-report.md")
    # remote-report.md may or may not exist in sample; if missing → 404, if present → 200
    assert resp.status_code in (200, 404)


def test_api_targets_200(client):
    resp = client.get("/api/targets")
    assert resp.status_code == 200
    data = json.loads(resp.data)
    assert isinstance(data, list)
    assert len(data) == 1
    assert data[0]["name"] == "report-2026-04-28"


def test_api_findings_200(client):
    resp = client.get("/api/targets/report-2026-04-28/findings")
    assert resp.status_code == 200
    data = json.loads(resp.data)
    assert isinstance(data, list)
    assert len(data) == 2


def test_api_findings_404(client):
    resp = client.get("/api/targets/nonexistent/findings")
    assert resp.status_code == 404


def test_api_score_200(client):
    resp = client.get("/api/targets/report-2026-04-28/score")
    assert resp.status_code == 200
    data = json.loads(resp.data)
    assert data["grade"] == "C"


def test_api_score_404(client):
    resp = client.get("/api/targets/nonexistent/score")
    assert resp.status_code == 404


def test_create_app_invalid_dir(tmp_path):
    with pytest.raises(ValueError, match="no findings.json"):
        create_app(tmp_path)
```

- [ ] **Step 2: Run to verify tests fail**

```bash
.venv/bin/pytest tests/serve/test_app.py -v
```
Expected: FAIL with `ModuleNotFoundError: No module named 'agentsec.serve.app'`

- [ ] **Step 3: Implement app.py**

Create `src/agentsec/serve/app.py`:

```python
"""Flask app factory for agentsec report viewer."""
from __future__ import annotations

import dataclasses
from pathlib import Path

from flask import Flask, abort, jsonify, redirect, render_template, url_for

from .reader import (
    TargetSummary,
    detect_mode,
    list_targets,
    load_findings_raw,
    parse_scores,
    render_markdown_file,
)

_ALLOWED_REPORTS = frozenset({
    "combined-report.md",
    "remote-report.md",
    "server-audit-report.md",
})


def create_app(output_dir: Path) -> Flask:
    output_dir = output_dir.resolve()
    mode = detect_mode(output_dir)
    if mode is None:
        raise ValueError(
            f"{output_dir}: no findings.json found "
            "(neither directly nor in subdirectories)"
        )

    app = Flask(__name__, template_folder="templates")
    app.config["SERVE_OUTPUT_DIR"] = output_dir
    app.config["SERVE_MODE"] = mode
    app.config["SERVE_TARGETS"] = {
        t.name: t for t in list_targets(output_dir, mode)
    }

    @app.route("/")
    def index():
        targets = list(app.config["SERVE_TARGETS"].values())
        if app.config["SERVE_MODE"] == "single":
            return redirect(url_for("target_detail", name=targets[0].name))
        return render_template("dashboard.html", targets=targets)

    @app.route("/target/<name>")
    def target_detail(name: str):
        t: TargetSummary | None = app.config["SERVE_TARGETS"].get(name)
        if t is None:
            abort(404)
        findings = load_findings_raw(t.output_dir)
        scores = parse_scores(t.output_dir)
        report_links = sorted(
            f for f in _ALLOWED_REPORTS if (t.output_dir / f).exists()
        )
        return render_template(
            "target.html",
            target=t,
            findings=findings,
            scores=scores,
            report_links=report_links,
        )

    @app.route("/target/<name>/report/<filename>")
    def target_report(name: str, filename: str):
        if filename not in _ALLOWED_REPORTS:
            abort(400)
        t: TargetSummary | None = app.config["SERVE_TARGETS"].get(name)
        if t is None:
            abort(404)
        html = render_markdown_file(t.output_dir, filename)
        if html is None:
            abort(404)
        return render_template(
            "report.html", target=t, filename=filename, content=html
        )

    @app.route("/api/targets")
    def api_targets():
        targets = list(app.config["SERVE_TARGETS"].values())
        return jsonify([_target_to_dict(t) for t in targets])

    @app.route("/api/targets/<name>/findings")
    def api_findings(name: str):
        t: TargetSummary | None = app.config["SERVE_TARGETS"].get(name)
        if t is None:
            abort(404)
        return jsonify(load_findings_raw(t.output_dir))

    @app.route("/api/targets/<name>/score")
    def api_score(name: str):
        t: TargetSummary | None = app.config["SERVE_TARGETS"].get(name)
        if t is None:
            abort(404)
        scores = parse_scores(t.output_dir)
        if scores is None:
            return jsonify({"grade": "UNKNOWN"})
        return jsonify(dataclasses.asdict(scores))

    return app


def _target_to_dict(t: TargetSummary) -> dict:
    return {
        "name": t.name,
        "grade": t.grade,
        "combined_score": t.combined_score,
        "remote_score": t.remote_score,
        "server_score": t.server_score,
        "critical_count": t.critical_count,
        "high_count": t.high_count,
        "has_findings": t.has_findings,
    }
```

- [ ] **Step 4: Run to verify template error**

```bash
.venv/bin/pytest tests/serve/test_app.py -v
```
Expected: FAIL with `TemplateNotFound: dashboard.html` or similar — confirms app.py is loading.

- [ ] **Step 5: Create templates**

Create `src/agentsec/serve/templates/base.html`:

```html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>AgentSec Report Viewer</title>
  <link rel="stylesheet"
        href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
        integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
        crossorigin="anonymous">
  <style>
    .badge-critical { background-color: #dc3545; }
    .badge-high     { background-color: #fd7e14; }
    .badge-medium   { background-color: #ffc107; color: #212529; }
    .badge-low      { background-color: #0dcaf0; color: #212529; }
    .badge-info     { background-color: #6c757d; }
    .grade-a, .grade-b  { color: #198754; font-weight: bold; }
    .grade-c            { color: #fd7e14; font-weight: bold; }
    .grade-d, .grade-f,
    .grade-low_cov,
    .grade-insufficient_coverage { color: #dc3545; font-weight: bold; }
    .grade-error, .grade-unknown { color: #6c757d; font-weight: bold; }
    .markdown-body { max-width: 860px; }
    .markdown-body table { width: 100%; border-collapse: collapse; }
    .markdown-body table th,
    .markdown-body table td { border: 1px solid #dee2e6; padding: .4rem .6rem; }
  </style>
</head>
<body>
  <nav class="navbar navbar-dark bg-dark px-3 mb-4">
    <a class="navbar-brand" href="/">AgentSec Report Viewer</a>
  </nav>
  <div class="container-lg">
    {% block content %}{% endblock %}
  </div>
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
          integrity="sha384-YvpcrYf0tY3lHB60NNkmXc4s9bIOgUxi8T/jzmEvMnFECZTkOrz6FfJV3LRjGhk"
          crossorigin="anonymous"></script>
  {% block scripts %}{% endblock %}
</body>
</html>
```

Create `src/agentsec/serve/templates/dashboard.html`:

```html
{% extends "base.html" %}
{% block content %}
<h2 class="mb-3">Evaluation Dashboard</h2>
<p class="text-muted">{{ targets | length }} target(s)</p>
<table class="table table-hover table-bordered">
  <thead class="table-dark">
    <tr>
      <th>Target</th><th>Grade</th><th>Score</th>
      <th>Remote</th><th>Server</th><th>Critical</th><th>High</th>
    </tr>
  </thead>
  <tbody>
  {% for t in targets %}
  <tr{% if not t.has_findings %} class="table-warning"{% endif %}>
    <td>
      <a href="/target/{{ t.name }}">{{ t.name }}</a>
      {% if not t.has_findings %}
        <span class="badge bg-warning text-dark ms-1">INCOMPLETE</span>
      {% endif %}
    </td>
    <td><span class="grade-{{ t.grade | lower }}">{{ t.grade }}</span></td>
    <td>{{ "%.1f" | format(t.combined_score) if t.combined_score is not none else "—" }}</td>
    <td>{{ "%.1f" | format(t.remote_score)   if t.remote_score   is not none else "—" }}</td>
    <td>{{ "%.1f" | format(t.server_score)   if t.server_score   is not none else "—" }}</td>
    <td>{{ t.critical_count }}</td>
    <td>{{ t.high_count }}</td>
  </tr>
  {% endfor %}
  </tbody>
</table>
{% endblock %}
```

Create `src/agentsec/serve/templates/target.html`:

```html
{% extends "base.html" %}
{% block content %}
<nav aria-label="breadcrumb" class="mb-3">
  <ol class="breadcrumb">
    <li class="breadcrumb-item"><a href="/">Dashboard</a></li>
    <li class="breadcrumb-item active">{{ target.name }}</li>
  </ol>
</nav>

<div class="card mb-4">
  <div class="card-body">
    <h4 class="card-title">Score — {{ target.name }}</h4>
    <div class="row text-center">
      <div class="col">
        <div class="fs-1 grade-{{ (scores.grade if scores else 'unknown') | lower }}">
          {{ scores.grade if scores else "—" }}
        </div>
        <small class="text-muted">Grade</small>
      </div>
      <div class="col">
        <div class="fs-3">
          {{ "%.1f" | format(scores.combined_score)
             if scores and scores.combined_score is not none else "—" }}
        </div>
        <small class="text-muted">Combined</small>
      </div>
      <div class="col">
        <div class="fs-3">
          {{ "%.1f" | format(scores.remote_score)
             if scores and scores.remote_score is not none else "—" }}
        </div>
        <small class="text-muted">Remote</small>
      </div>
      <div class="col">
        <div class="fs-3">
          {{ "%.1f" | format(scores.server_score)
             if scores and scores.server_score is not none else "—" }}
        </div>
        <small class="text-muted">Server</small>
      </div>
      <div class="col">
        <div class="fs-3">
          {{ "%.0f%%" | format((scores.verdict_coverage or 0) * 100)
             if scores else "—" }}
        </div>
        <small class="text-muted">Coverage</small>
      </div>
      <div class="col">
        <div class="fs-3">
          {{ "%.0f%%" | format((scores.error_rate or 0) * 100)
             if scores else "—" }}
        </div>
        <small class="text-muted">Errors</small>
      </div>
    </div>
  </div>
</div>

<div class="mb-3 d-flex flex-wrap gap-2 align-items-center">
  <strong>Filter by severity:</strong>
  {% for sev in ["critical", "high", "medium", "low", "info"] %}
  <div class="form-check form-check-inline mb-0">
    <input class="form-check-input sev-filter" type="checkbox"
           value="{{ sev }}" id="sev-{{ sev }}" checked>
    <label class="form-check-label" for="sev-{{ sev }}">
      <span class="badge badge-{{ sev }}">{{ sev }}</span>
    </label>
  </div>
  {% endfor %}
  <input type="text" id="check-filter" class="form-control form-control-sm ms-2"
         style="max-width:200px" placeholder="Filter by check…">
</div>

<table class="table table-sm table-hover table-bordered" id="findings-table">
  <thead class="table-secondary">
    <tr>
      <th>Check</th><th>Severity</th><th>Title</th>
      <th>Description</th><th>Remediation</th>
    </tr>
  </thead>
  <tbody>
  {% for f in findings %}
  <tr data-severity="{{ f.severity }}" data-check="{{ f.check }}">
    <td><code>{{ f.check }}</code></td>
    <td><span class="badge badge-{{ f.severity }}">{{ f.severity }}</span></td>
    <td>{{ f.title }}</td>
    <td>{{ f.description }}</td>
    <td>{{ f.remediation }}</td>
  </tr>
  {% else %}
  <tr><td colspan="5" class="text-center text-muted">No findings</td></tr>
  {% endfor %}
  </tbody>
</table>
<p class="text-muted small" id="findings-count">{{ findings | length }} finding(s)</p>

{% if report_links %}
<h5 class="mt-4">Reports</h5>
<ul>
  {% for r in report_links %}
  <li><a href="/target/{{ target.name }}/report/{{ r }}">{{ r }}</a></li>
  {% endfor %}
</ul>
{% endif %}
{% endblock %}

{% block scripts %}
<script>
(function () {
  function applyFilters() {
    var checked = new Set(
      Array.from(document.querySelectorAll('.sev-filter:checked')).map(function (el) {
        return el.value;
      })
    );
    var checkText = document.getElementById('check-filter').value.toLowerCase();
    var visible = 0;
    document.querySelectorAll('#findings-table tbody tr[data-severity]').forEach(function (row) {
      var show = checked.has(row.dataset.severity) &&
                 row.dataset.check.toLowerCase().indexOf(checkText) !== -1;
      row.style.display = show ? '' : 'none';
      if (show) { visible++; }
    });
    document.getElementById('findings-count').textContent = visible + ' finding(s)';
  }
  document.querySelectorAll('.sev-filter').forEach(function (el) {
    el.addEventListener('change', applyFilters);
  });
  document.getElementById('check-filter').addEventListener('input', applyFilters);
}());
</script>
{% endblock %}
```

Create `src/agentsec/serve/templates/report.html`:

```html
{% extends "base.html" %}
{% block content %}
<nav aria-label="breadcrumb" class="mb-3">
  <ol class="breadcrumb">
    <li class="breadcrumb-item"><a href="/">Dashboard</a></li>
    <li class="breadcrumb-item"><a href="/target/{{ target.name }}">{{ target.name }}</a></li>
    <li class="breadcrumb-item active">{{ filename }}</li>
  </ol>
</nav>
<div class="markdown-body">
  {{ content | safe }}
</div>
{% endblock %}
```

- [ ] **Step 6: Run tests to verify they pass**

```bash
.venv/bin/pytest tests/serve/test_app.py -v
```
Expected: 13 passed, 0 failed.

- [ ] **Step 7: Commit**

```bash
git add src/agentsec/serve/app.py src/agentsec/serve/templates/ tests/serve/test_app.py
git commit -m "feat(serve): add Flask app factory, routes, and Bootstrap templates"
```

---

## Task 3: CLI `serve` command

**Files:**
- Modify: `src/agentsec/cli.py`

- [ ] **Step 1: Add serve command**

In `src/agentsec/cli.py`, insert the following block after the `diff` command
and before the `@app.command("ioc-update")` line (around line 743):

```python
@app.command()
def serve(
    output_dir: Path = typer.Argument(..., help="Report output directory (single or multi-target)"),
    port: int = typer.Option(8080, "--port", help="TCP port"),
    host: str = typer.Option("127.0.0.1", "--host", help="Bind address"),
) -> None:
    """Serve an agentsec evaluate report directory as a local web dashboard."""
    from .serve.app import create_app

    try:
        app_instance = create_app(output_dir)
    except ValueError as exc:
        typer.echo(f"error: {exc}", err=True)
        raise typer.Exit(code=1) from exc

    typer.echo(f"Serving AgentSec report at http://{host}:{port}")
    typer.echo("Press Ctrl+C to stop.")
    app_instance.run(host=host, port=port, use_reloader=False)
```

- [ ] **Step 2: Verify help text**

```bash
.venv/bin/agentsec serve --help
```
Expected output contains:
```
Usage: agentsec serve [OPTIONS] OUTPUT_DIR
  Serve an agentsec evaluate report directory as a local web dashboard.
Options:
  --port INTEGER  TCP port  [default: 8080]
  --host TEXT     Bind address  [default: 127.0.0.1]
```

- [ ] **Step 3: Run full test suite**

```bash
.venv/bin/pytest tests/ -q
```
Expected: 750+ passed, 0 failed.

- [ ] **Step 4: Lint**

```bash
.venv/bin/ruff check src/ --fix
```
Expected: no errors.

- [ ] **Step 5: Commit**

```bash
git add src/agentsec/cli.py
git commit -m "feat(cli): add serve command for local report web dashboard"
```

---

## Task 4: CHANGELOG and ROADMAP

**Files:**
- Modify: `CHANGELOG.md`
- Modify: `ROADMAP.md`

- [ ] **Step 1: Update CHANGELOG.md**

At the top of `CHANGELOG.md` (after the first `# Changelog` heading), add:

```markdown
## [v0.7.0] — 2026-05-04

### Added
- `agentsec serve <output-dir> [--port 8080] [--host 127.0.0.1]` — local Flask web
  dashboard for browsing `agentsec evaluate` / `agentsec multi-evaluate` artifacts.
  Auto-detects single-target vs multi-target layout. Features: score card (grade,
  combined/remote/server scores, verdict coverage, error rate), filterable findings
  table (severity checkboxes + check-name text filter), Markdown report rendering.
- New module `src/agentsec/serve/` with `reader.py` (pure I/O helpers) and `app.py`
  (Flask routes + Jinja2/Bootstrap 5 templates).
- New dependencies: `flask>=3.0`, `markdown>=3.6`.
```

- [ ] **Step 2: Update ROADMAP.md**

In Phase 3, change:
```
- [ ] Web UI 报告查看器（消费 `findings.json` + `combined-report.md`）
```
to:
```
- [x] Web UI 报告查看器（`agentsec serve`，Flask + Bootstrap 5）— v0.7.0，2026-05-04
```

- [ ] **Step 3: Commit**

```bash
git add CHANGELOG.md ROADMAP.md
git commit -m "chore: update CHANGELOG and ROADMAP for v0.7.0 report viewer"
```
