Skip to content
Open
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
15 changes: 11 additions & 4 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,16 @@ RUN npm run build
# ---------------------------------------------------------------------------
FROM python:3.12-slim AS runtime

# Install git (required by gitpython) and Node.js (required for Next.js server)
# Install git (required by gitpython)
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
curl \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*

# Copy Node.js from the official image instead of piping curl|bash
COPY --from=frontend-builder /usr/local/bin/node /usr/local/bin/node
COPY --from=frontend-builder /usr/local/lib/node_modules /usr/local/lib/node_modules
RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm

WORKDIR /app

# Install repowise Python package
Expand Down Expand Up @@ -67,4 +69,9 @@ EXPOSE 7337 3000
COPY docker/entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh

# Run as non-root user (mitigates container escape → root escalation)
RUN groupadd -r repowise && useradd -r -g repowise -d /app -s /sbin/nologin repowise \
&& chown -R repowise:repowise /app /data
USER repowise

ENTRYPOINT ["/app/entrypoint.sh"]
12 changes: 10 additions & 2 deletions docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,22 @@ services:
repowise:
build: .
ports:
- "7337:7337" # API
- "3000:3000" # Web UI
- "127.0.0.1:7337:7337" # API — loopback only by default
- "127.0.0.1:3000:3000" # Web UI — loopback only by default
volumes:
# Mount the .repowise directory from your indexed repo
- ${REPOWISE_DATA:-./data}:/data
# Mount repo directory as read-only to prevent write-based attacks
- type: bind
source: ${REPO_PATH:?Set REPO_PATH to your repository directory}
target: /repos
read_only: true
environment:
- REPOWISE_DB_URL=sqlite+aiosqlite:///data/wiki.db
- REPOWISE_EMBEDDER=${REPOWISE_EMBEDDER:-mock}
- REPOWISE_HOST=0.0.0.0
# SECURITY: Set this to enable API authentication (required for non-loopback)
- REPOWISE_API_KEY=${REPOWISE_API_KEY:?Set REPOWISE_API_KEY for Docker deployments}
# Set your LLM provider API key
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
Expand Down
28 changes: 25 additions & 3 deletions packages/server/src/repowise/server/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

from __future__ import annotations

import hmac
import logging
import os
from collections.abc import AsyncGenerator

Expand All @@ -18,9 +20,21 @@

from repowise.core.persistence.database import get_session

logger = logging.getLogger(__name__)

_API_KEY = os.environ.get("REPOWISE_API_KEY")
_REPOWISE_HOST = os.environ.get("REPOWISE_HOST", "127.0.0.1")
_header_scheme = APIKeyHeader(name="Authorization", auto_error=False)

# Warn at import time if server is network-exposed without authentication
if _API_KEY is None and _REPOWISE_HOST in ("0.0.0.0", "::"):
logger.warning(
"SECURITY WARNING: Server is binding to %s without REPOWISE_API_KEY set. "
"All endpoints are unauthenticated and network-accessible. "
"Set REPOWISE_API_KEY or bind to 127.0.0.1.",
_REPOWISE_HOST,
)


async def get_db_session(request: Request) -> AsyncGenerator[AsyncSession, None]:
"""Yield an async DB session with auto-commit on success, rollback on error."""
Expand All @@ -42,14 +56,22 @@ async def get_fts(request: Request):
async def verify_api_key(
auth: str | None = Security(_header_scheme),
) -> None:
"""Optional API key verification.
"""API key verification.

When REPOWISE_API_KEY is not set, this is a no-op (fully open).
When REPOWISE_API_KEY is not set and server binds to loopback, this is a
no-op (local-only access). When binding to a non-loopback address without
a key, requests are rejected (fail-closed for network-exposed deployments).
When set, requests must include ``Authorization: Bearer <key>``.
"""
if _API_KEY is None:
if _REPOWISE_HOST in ("0.0.0.0", "::"):
raise HTTPException(
status_code=503,
detail="Server is network-exposed but REPOWISE_API_KEY is not set. "
"Set REPOWISE_API_KEY or bind to 127.0.0.1.",
)
return
if not auth or not auth.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing API key")
if auth[7:] != _API_KEY:
if not hmac.compare_digest(auth[7:], _API_KEY):
raise HTTPException(status_code=401, detail="Invalid API key")
13 changes: 11 additions & 2 deletions packages/server/src/repowise/server/mcp_server/tool_why.py
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,11 @@ async def _run_git_log(
import subprocess

def _sync_git_log() -> list[dict]:
import re

results: list[dict] = []
# Sanitize stem to prevent argument injection via --grep
safe_stem = re.sub(r"[^a-zA-Z0-9_\-.]", "", stem) if stem else ""
try:
proc = subprocess.run(
["git", "log", "--follow", "--format=%H\t%an\t%ai\t%s", "-20", "--", file_path],
Expand All @@ -487,9 +491,14 @@ def _sync_git_log() -> list[dict]:
}
)

if stem and len(stem) >= 3:
if safe_stem and len(safe_stem) >= 3:
proc2 = subprocess.run(
["git", "log", "--all", "--grep", stem, "--format=%H\t%an\t%ai\t%s", "-10"],
[
"git", "log", "--all",
"--grep", safe_stem,
"--format=%H\t%an\t%ai\t%s", "-10",
"--", # end of options — prevent argument injection
],
cwd=repo_path,
capture_output=True,
text=True,
Expand Down
15 changes: 14 additions & 1 deletion packages/server/src/repowise/server/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@

import json
from datetime import datetime
from pathlib import Path

from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator

# ---------------------------------------------------------------------------
# Repository
Expand All @@ -19,6 +20,18 @@ class RepoCreate(BaseModel):
default_branch: str = "main"
settings: dict | None = None

@field_validator("local_path")
@classmethod
def validate_local_path(cls, v: str) -> str:
resolved = Path(v).resolve()
if ".." in Path(v).parts:
raise ValueError("local_path must not contain '..' segments")
if not resolved.is_dir():
raise ValueError(f"local_path does not exist or is not a directory: {resolved}")
if not (resolved / ".git").exists():
raise ValueError(f"local_path is not a git repository (no .git found): {resolved}")
return str(resolved)


class RepoUpdate(BaseModel):
name: str | None = None
Expand Down