fix: code audit — security, correctness, and robustness fixes

Security:
- Path traversal: validate user_id, case_id, skill_name against safe regex
- Input sanitization in memory.py and skills.py file path construction

Correctness:
- ISR timezone: replace hardcoded UTC+3 with ZoneInfo("Asia/Jerusalem") for DST
- save_rule: use EspoCrmApiError.status_code instead of fragile string matching
- Background tasks: store references to prevent GC before completion
- Lock race: use dict.setdefault() for atomic lock creation
- Version: health.py now matches main.py (0.2.0)
- Skill names: normalized to lowercase for dedup consistency

Build:
- Dockerfile: install from pyproject.toml instead of hardcoded pip list
- Remove unused mcp>=1.0.0 dependency from pyproject.toml

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 20:18:01 +00:00
parent a990f527a2
commit e947bca655
9 changed files with 59 additions and 21 deletions
+15 -7
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
import logging
import os
import re
import tempfile
from pathlib import Path
@@ -18,14 +19,21 @@ PROFILE_MAX_CHARS = 1500
CASE_MEMORY_MAX_CHARS = 2500
ENTRY_DELIMITER = "\n§\n"
_SAFE_ID_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
# Per-path locks for thread-safe writes
_locks: dict[str, asyncio.Lock] = {}
def _safe_id(value: str) -> str:
"""Validate that an ID is safe for use in file paths."""
if not value or not _SAFE_ID_RE.match(value):
raise ValueError(f"Invalid ID for file path: {value!r}")
return value
def _get_lock(path: str) -> asyncio.Lock:
if path not in _locks:
_locks[path] = asyncio.Lock()
return _locks[path]
return _locks.setdefault(path, asyncio.Lock())
def _read_file(path: str) -> str:
@@ -75,17 +83,17 @@ def _truncate(content: str, max_chars: int) -> str:
def load_user_profile(user_id: str) -> str:
"""Load the per-lawyer profile. Returns empty string if none exists."""
return _read_file(f"{PROFILES_DIR}/{user_id}.md")
return _read_file(f"{PROFILES_DIR}/{_safe_id(user_id)}.md")
def load_case_memory(case_id: str) -> str:
"""Load the per-case memory. Returns empty string if none exists."""
return _read_file(f"{CASES_DIR}/{case_id}/memory.md")
return _read_file(f"{CASES_DIR}/{_safe_id(case_id)}/memory.md")
async def update_user_profile(user_id: str, action: str, content: str, match: str = "") -> str:
"""Add, replace, or remove an entry in the lawyer's profile."""
path = f"{PROFILES_DIR}/{user_id}.md"
path = f"{PROFILES_DIR}/{_safe_id(user_id)}.md"
async with _get_lock(path):
current = _read_file(path)
entries = _entries(current)
@@ -129,7 +137,7 @@ async def update_user_profile(user_id: str, action: str, content: str, match: st
async def update_case_memory(case_id: str, action: str, content: str, match: str = "") -> str:
"""Add, replace, or remove an entry in the case memory."""
path = f"{CASES_DIR}/{case_id}/memory.md"
path = f"{CASES_DIR}/{_safe_id(case_id)}/memory.md"
async with _get_lock(path):
current = _read_file(path)
entries = _entries(current)