Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
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`**, **`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
54 changes: 54 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,54 @@
# 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.**

Report vulnerabilities privately via [GitHub Security Advisories](https://github.com/cppalliance/claude-code-chat-browser/security/advisories/new). Private vulnerability reporting must be enabled on the repository (Settings → Security → Private vulnerability reporting). If you cannot use that form, contact the repository maintainers through an existing private channel.

## 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`) |
33 changes: 10 additions & 23 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
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 @@ -163,10 +157,7 @@ def _on_export_error(sid: str, exc: Exception) -> None:
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 +174,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 +192,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
2 changes: 1 addition & 1 deletion models/export.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Export state file shapes."""

from typing import NotRequired, TypedDict
from typing import TypedDict


class ExportStateDict(TypedDict, total=False):
Expand Down
16 changes: 16 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,19 @@ omit = [
[tool.coverage.report]
fail_under = 60
include = ["api/*", "utils/*"]

[tool.ruff]
target-version = "py312"
line-length = 100

[tool.ruff.lint]
select = ["E", "F", "W", "I"]

[tool.ruff.lint.isort]
combine-as-imports = true

[tool.ruff.lint.per-file-ignores]
# CLI bootstrap: sys.path must be set before local imports.
"scripts/export.py" = ["E402"]
# Tests mirror the same path bootstrap before importing app/utils.
"tests/*.py" = ["E402"]
2 changes: 2 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ pytest==9.0.2
mypy==1.15.0
types-Flask==1.1.6
pytest-cov>=5.0
ruff>=0.9.0
pip-audit>=2.7.0
Loading
Loading