Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Comment thread
clean6378-max-it marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,40 @@ jobs:
assert client.get("/").status_code == 200
PY

lint-and-audit:
name: ruff + pip-audit (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
permissions:
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false

- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: |
requirements.txt
requirements-dev.txt

- name: Install dev dependencies
run: pip install -r requirements-dev.txt

- name: Ruff
run: ruff check .

- name: Ruff format check
run: ruff format --check .

- name: pip-audit
run: pip-audit -r requirements.txt

pytest:
name: pytest (${{ matrix.os }})
runs-on: ${{ matrix.os }}
Expand Down
11 changes: 8 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Thanks for considering a patch. This repo is a small Flask app plus a hash-route
- **Python 3.12** (matches CI)
- **Node 20+** (only if you change `static/js/` or run frontend unit tests)

CI runs **`pytest`**, **integration tests**, and **Vitest** on **ubuntu-latest** and **windows-latest** (Python 3.12, Node 20). Type-check (`mypy`) and production install smoke run on Ubuntu only.
CI runs **`ruff check`**, **`ruff format --check`**, **`pip-audit`**, **`pytest`**, **integration tests**, and **Vitest** on **ubuntu-latest** and **windows-latest** (Python 3.12, Node 20). Type-check (`mypy`) and production install smoke run on Ubuntu only.

### Bootstrap (Windows PowerShell)

Expand Down Expand Up @@ -58,6 +58,9 @@ When changing JSON response shapes, update the API reference stability column an
### Python

```bash
ruff check . # lint (E, F, W, I) — same gate as CI
ruff format --check . # formatting gate; run `ruff format .` to fix
pip-audit -r requirements.txt # production dependency audit (CI gate)
pytest -q # full suite + coverage (see pyproject.toml)
pytest tests/test_api_integration.py -v
pytest tests/test_search.py -v
Expand Down Expand Up @@ -85,7 +88,8 @@ npm run test:coverage # optional
| **Exception leakage** | `5xx` bodies are generic messages only. Log full tracebacks with `current_app.logger.exception(...)`. Never put `str(e)` or class names in HTTP JSON (issue #25). |
| **Path safety** | Use `safe_join()` from `utils/session_path.py` for any path built from URL segments. |
| **Imports** | stdlib → third-party → local, blank line between groups. |
| **Line length** | ~100 characters; no enforced formatter yet. |
| **Lint / format** | `ruff check .` and `ruff format --check .` (CI gates). Config in `pyproject.toml`; run `ruff format .` to apply formatting locally. |
| **Line length** | 100 characters (`line-length` in `pyproject.toml`). |

## Tests required for common changes

Expand All @@ -105,9 +109,10 @@ npm run test:coverage # optional
- Branch names: `feat/<topic>`, `fix/<topic>`, `test/<topic>`, `chore/<topic>`, `docs/<topic>`.
- One logical change per PR when possible.
- PR checklist:
- [ ] `ruff check .` and `ruff format --check .` green locally
- [ ] `pytest -q` green locally
- [ ] `npm test` green if JS changed
- [ ] CI jobs green (`pytest`, `integration-tests`, `js-tests` on Ubuntu + Windows; `mypy`, `prod-install-smoke` on Ubuntu)
- [ ] CI jobs green (`lint-and-audit`, `pytest`, `integration-tests`, `js-tests` on Ubuntu + Windows; `mypy`, `prod-install-smoke` on Ubuntu)
- [ ] PR description includes a **Test plan** section
- [ ] API changes update [`docs/api-reference.md`](docs/api-reference.md) if behavior or errors change

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ REST endpoints for projects, sessions, search, and export are documented in **[`

JSON error responses include a machine-readable `"code"` (stable `UPPER_SNAKE_CASE`) and a human-readable `"error"` message. See the [error code catalog](docs/api-reference.md#error-code-catalog) for the full table.

- **Security policy** — see [`SECURITY.md`](SECURITY.md) for supported versions and how to report vulnerabilities privately

### CLI Export
- Standalone script to export all sessions to Markdown with YAML frontmatter
- Rich Markdown: token usage, tool calls, thinking blocks, model info, timestamps
Expand Down Expand Up @@ -155,7 +157,7 @@ npm ci && npm test # only if you changed static/js/

## Continuous integration

Every push and pull request runs **`pytest`**, **API integration tests**, and **vitest** on **Ubuntu** (Python 3.12, Node 20) via [`.github/workflows/ci.yml`](.github/workflows/ci.yml). A separate job verifies that `pip install -r requirements.txt` (production-only) is sufficient to import and boot the app.
Every push and pull request runs **`ruff check`**, **`ruff format --check`**, **`pip-audit`**, **`pytest`**, **mypy**, **API integration tests**, and **vitest** on **Ubuntu and Windows** (Python 3.12, Node 20) via [`.github/workflows/ci.yml`](.github/workflows/ci.yml). A separate job verifies that `pip install -r requirements.txt` (production-only) is sufficient to import and boot the app.

## Exported Markdown Format

Expand Down
58 changes: 58 additions & 0 deletions SECURITY.md
Comment thread
clean6378-max-it marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Security Policy

## Supported Versions

This project is pre-release. Security fixes are applied to the **latest `master` branch only** (currently `0.1.0.dev0`).

| Version | Supported |
| -------------- | --------- |
| latest `master` | Yes |
| older commits | No |

## Reporting a Vulnerability

**Please do not open public GitHub issues for security vulnerabilities.**

**Primary path (always works):** Contact a [repository maintainer](https://github.com/cppalliance/claude-code-chat-browser/graphs/contributors) through a private channel you already use with the project (for example a direct message or private email). Include steps to reproduce, affected version/commit, and impact.

**GitHub Security Advisories (when enabled):** Once **Private vulnerability reporting** is turned on for this repository (Settings → Security → Private vulnerability reporting), external researchers may use the [private advisory form](https://github.com/cppalliance/claude-code-chat-browser/security/advisories/new). If that link returns 404 or the form is unavailable, use the primary path above — the advisory URL only works when the setting is enabled.

**Repository admins:** Enable private vulnerability reporting before merge if you want the advisory form to be the default reporter path; until then, maintainers should treat the primary path as authoritative.

## Response Timeline

| Stage | Target |
| ----- | ------ |
| Acknowledgment | Within **72 hours** of a valid report |
| Initial assessment | Within **7 days** |
| Fix for confirmed issues | Target **14 days** for issues affecting the default local-only deployment |

Timelines may extend for complex issues; we will keep reporters informed.

## Scope

### In scope

Security issues in this repository that affect users of the default local setup:

- **Path traversal** — session and export paths resolved via `safe_join` in `utils/session_path.py`
- **Cross-site scripting (XSS)** — rendered session HTML in `static/js/` (mitigated by DOMPurify + SRI in `static/index.html`)
- **Export integrity** — bulk zip and per-session export in `api/export_api.py` and `utils/export_engine.py`
- **Local file boundaries** — read-only access to `~/.claude/projects/`; writes limited to export output and app state
- **Debug-mode exposure** — Flask/Werkzeug debugger when `--debug` is combined with a non-loopback `--host` (blocked at startup in `app.py`)
- **Information disclosure** — API error responses scrub internal exception details (see `api/error_codes.py`)

### Out of scope

- **Intentional network-facing deployment** — this tool is designed for local browsing on loopback; exposing it on untrusted networks is not a supported configuration
- **Upstream Claude Code JSONL format bugs** — malformed or hostile data from Claude Code itself (we harden parsing but do not guarantee full isolation from arbitrary JSONL)
- **Third-party CDN availability** — DOMPurify is loaded from cdnjs with SRI; CDN compromise is an infrastructure concern outside this repo

## Existing Controls (reference)

| Control | Location |
| ------- | -------- |
| Path guard (`safe_join`) | `utils/session_path.py` |
| HTML sanitization (DOMPurify) | `static/js/shared/markdown.js`, `static/index.html` |
| Error response scrubbing | `api/error_codes.py`, session card handling in `api/projects.py` |
| Debug + non-loopback host guard | `app.py` (`validate_startup_cli`) |
35 changes: 10 additions & 25 deletions api/export_api.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"""Export endpoints -- bulk zip download and single-session md/json."""

import io
import json
import os
import zipfile
from datetime import datetime
Expand All @@ -12,20 +11,20 @@
from api._flask_types import FlaskReturn, json_response
from api.error_codes import ErrorCode, error_response
from models.export import ExportStateDict
from utils.exclusion_rules import is_session_excluded
from utils.export_engine import EXPORT_ERRORS as _EXPORT_ERRORS, ZipSink, run_bulk_export
from utils.export_state_store import (
EXPORT_STATE_FILE,
atomic_write_export_state,
export_state_lock,
load_export_state_from_disk,
)
from utils.session_path import get_claude_projects_dir, list_projects
from utils.json_exporter import session_to_json
from utils.jsonl_parser import parse_session
from utils.exclusion_rules import is_session_excluded
from utils.session_stats import compute_stats
from utils.md_exporter import session_to_markdown
from utils.json_exporter import session_to_json
from utils.session_path import get_claude_projects_dir, list_projects, safe_join
from utils.session_stats import compute_stats
from utils.slugify import slugify
from utils.export_engine import EXPORT_ERRORS as _EXPORT_ERRORS, ZipSink, run_bulk_export

export_bp = Blueprint("export", __name__)

Expand Down Expand Up @@ -94,10 +93,7 @@ def bulk_export() -> FlaskReturn:
since=since,
)

base = (
current_app.config.get("CLAUDE_PROJECTS_DIR")
or get_claude_projects_dir()
)
base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
projects = list_projects(base)
rules = current_app.config.get("EXCLUSION_RULES") or []

Expand All @@ -109,9 +105,7 @@ def bulk_export() -> FlaskReturn:
buf = io.BytesIO()

def _on_export_error(sid: str, exc: Exception) -> None:
current_app.logger.warning(
"Failed to export %s: %s", sid[:10], exc
)
current_app.logger.warning("Failed to export %s: %s", sid[:10], exc)

with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
result = run_bulk_export(
Expand Down Expand Up @@ -161,12 +155,7 @@ def _on_export_error(sid: str, exc: Exception) -> None:

@export_bp.route("/api/export/session/<path:project_name>/<session_id>")
def export_session(project_name: str, session_id: str) -> FlaskReturn:
from utils.session_path import safe_join

base = (
current_app.config.get("CLAUDE_PROJECTS_DIR")
or get_claude_projects_dir()
)
base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
try:
filepath = safe_join(base, project_name, f"{session_id}.jsonl")
except ValueError:
Expand All @@ -183,9 +172,7 @@ def export_session(project_name: str, session_id: str) -> FlaskReturn:
try:
session = parse_session(filepath)
except _EXPORT_ERRORS:
current_app.logger.exception(
"Failed to parse session %s for export", session_id
)
current_app.logger.exception("Failed to parse session %s for export", session_id)
return error_response(
ErrorCode.PARSE_ERROR,
"Failed to parse session",
Expand All @@ -203,9 +190,7 @@ def export_session(project_name: str, session_id: str) -> FlaskReturn:
try:
stats = compute_stats(session)
except _EXPORT_ERRORS:
current_app.logger.exception(
"Failed to compute stats for export %s", session_id
)
current_app.logger.exception("Failed to compute stats for export %s", session_id)
return error_response(
ErrorCode.INTERNAL_ERROR,
"Failed to compute session stats",
Expand Down
6 changes: 4 additions & 2 deletions api/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@

from flask import Blueprint, current_app

from api._flask_types import FlaskReturn, json_error, json_response
from api._flask_types import FlaskReturn, json_response
from api.error_codes import ErrorCode, error_response
from models.project import ProjectSessionRowDict, SessionListItemDict
from models.session import SessionDict
from utils.session_path import get_claude_projects_dir, list_projects, list_sessions, safe_join
from utils.exclusion_rules import is_session_excluded
from utils.session_path import get_claude_projects_dir, list_projects, list_sessions, safe_join

projects_bp = Blueprint("projects", __name__)

Expand Down Expand Up @@ -49,6 +49,7 @@ def get_projects() -> FlaskReturn:
# so the landing page matches what the workspace page shows.
# Uses quick_session_info() which peeks at files without full parsing.
from utils.jsonl_parser import quick_session_info

for project in projects:
sessions = list_sessions(project["path"])
titled_count = 0
Expand Down Expand Up @@ -81,6 +82,7 @@ def get_project_sessions(project_name: str) -> FlaskReturn:
sessions = list_sessions(project_dir)
# Add summary preview for each session
from utils.jsonl_parser import parse_session

rules = current_app.config.get("EXCLUSION_RULES") or []
result: list[ProjectSessionRowDict] = []
for s in sessions:
Expand Down
24 changes: 12 additions & 12 deletions api/search.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
"""Search endpoint. Brute-force substring match across all sessions."""

import os

from flask import Blueprint, current_app, request

from api._flask_types import FlaskReturn, json_response
from api.error_codes import ErrorCode, error_response
from models.search import SearchHitDict
from utils.session_path import get_claude_projects_dir, list_projects, list_sessions
from utils.jsonl_parser import parse_session
from utils.exclusion_rules import is_session_excluded
from utils.jsonl_parser import parse_session
from utils.session_path import get_claude_projects_dir, list_projects, list_sessions

search_bp = Blueprint("search", __name__)

Expand Down Expand Up @@ -71,14 +69,16 @@ def search() -> FlaskReturn:
end = min(len(text), idx + len(query) + 80)
snippet = text[start:end]

results.append({
"project": project["name"],
"session_id": session["session_id"],
"title": session["title"],
"role": msg["role"],
"timestamp": msg.get("timestamp"),
"snippet": snippet,
})
results.append(
{
"project": project["name"],
"session_id": session["session_id"],
"title": session["title"],
"role": msg["role"],
"timestamp": msg.get("timestamp"),
"snippet": snippet,
}
)
if len(results) >= max_results:
break

Expand Down
12 changes: 2 additions & 10 deletions api/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@

from api._flask_types import FlaskReturn, json_response
from api.error_codes import ErrorCode, error_response
from utils.session_path import get_claude_projects_dir, safe_join
from utils.exclusion_rules import is_session_excluded
from utils.jsonl_parser import parse_session
from utils.session_path import get_claude_projects_dir, safe_join
from utils.session_stats import compute_stats
from utils.exclusion_rules import is_session_excluded

sessions_bp = Blueprint("sessions", __name__)

Expand Down Expand Up @@ -89,14 +89,6 @@ def get_session_stats(project_name: str, session_id: str) -> FlaskReturn:
500,
)

rules = current_app.config.get("EXCLUSION_RULES") or []
if is_session_excluded(rules, session, project_name):
return error_response(
ErrorCode.SESSION_NOT_FOUND,
"Session not found",
404,
)

try:
stats = compute_stats(session)
return json_response(stats)
Expand Down
17 changes: 10 additions & 7 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,15 @@
__version__ = "0.1.0.dev0"

import argparse
import os
import sys

from flask import Flask

from api.export_api import export_bp
from api.projects import projects_bp
from api.sessions import sessions_bp
from api.search import search_bp
from api.export_api import export_bp
from utils.exclusion_rules import resolve_exclusion_rules_path, load_rules
from api.sessions import sessions_bp
from utils.exclusion_rules import load_rules, resolve_exclusion_rules_path


def _normalize_bind_host(host: str) -> str:
Expand Down Expand Up @@ -100,15 +99,19 @@ def build_cli_parser() -> argparse.ArgumentParser:
"--debug",
action="store_true",
default=False,
help="Enable Flask/Werkzeug debug mode (never use with --host 0.0.0.0 on untrusted networks).",
help=(
"Enable Flask/Werkzeug debug mode "
"(never use with --host 0.0.0.0 on untrusted networks)."
),
)
parser.add_argument("--base-dir", default=None, help="Override Claude projects dir")
parser.add_argument(
"--exclude-rules", "-e",
"--exclude-rules",
"-e",
default=None,
metavar="PATH",
help="Path to exclusion rules file (sensitive sessions are omitted). "
"If omitted, uses ~/.claude-code-chat-browser/exclusion-rules.txt if present.",
"If omitted, uses ~/.claude-code-chat-browser/exclusion-rules.txt if present.",
)
return parser

Expand Down
Loading
Loading