feat: 实现用户权限控制、会话管理和审计日志功能
- 添加用户权限检查功能,支持配置允许使用的用户列表 - 实现会话管理功能,包括会话创建、关闭、列表和切换 - 新增审计日志模块,记录所有交互信息 - 改进WebSocket连接,增加自动重连机制 - 添加健康检查端点,包含Claude服务可用性测试 - 实现会话持久化功能,重启后恢复会话状态 - 增加命令行功能支持,包括/new、/list、/close等命令 - 优化消息处理流程,支持直接传递模式
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
"""Audit logging for Claude Code sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
AUDIT_DIR = Path(__file__).parent.parent / "audit"
|
||||
|
||||
|
||||
def _ensure_audit_dir() -> None:
|
||||
AUDIT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def log_interaction(
|
||||
conv_id: str,
|
||||
prompt: str,
|
||||
response: str,
|
||||
cwd: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Log an interaction to a JSONL file per session.
|
||||
|
||||
Args:
|
||||
conv_id: Conversation/session ID
|
||||
prompt: User's message/prompt
|
||||
response: Claude Code's response
|
||||
cwd: Working directory (optional)
|
||||
user_id: User identifier (optional)
|
||||
"""
|
||||
try:
|
||||
_ensure_audit_dir()
|
||||
log_file = AUDIT_DIR / f"{conv_id}.jsonl"
|
||||
|
||||
entry = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"conv_id": conv_id,
|
||||
"prompt": prompt,
|
||||
"response": response,
|
||||
}
|
||||
if cwd:
|
||||
entry["cwd"] = cwd
|
||||
if user_id:
|
||||
entry["user_id"] = user_id[-8:] if len(user_id) > 8 else user_id
|
||||
|
||||
with open(log_file, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||
|
||||
logger.debug("Logged interaction for session %s", conv_id)
|
||||
|
||||
except Exception:
|
||||
logger.exception("Failed to log audit entry for session %s", conv_id)
|
||||
|
||||
|
||||
def get_audit_log(conv_id: str, limit: int = 100) -> list[dict]:
|
||||
"""Read the audit log for a session."""
|
||||
log_file = AUDIT_DIR / f"{conv_id}.jsonl"
|
||||
if not log_file.exists():
|
||||
return []
|
||||
|
||||
entries = []
|
||||
try:
|
||||
with open(log_file, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
if line.strip():
|
||||
entries.append(json.loads(line))
|
||||
except Exception:
|
||||
logger.exception("Failed to read audit log for session %s", conv_id)
|
||||
|
||||
return entries[-limit:]
|
||||
+101
-36
@@ -3,45 +3,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from agent.pty_process import run_claude
|
||||
from agent.audit import log_interaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
IDLE_TIMEOUT = 30 * 60 # 30 minutes in seconds
|
||||
DEFAULT_IDLE_TIMEOUT = 30 * 60
|
||||
DEFAULT_CC_TIMEOUT = 300.0
|
||||
PERSISTENCE_FILE = Path(__file__).parent.parent / "sessions.json"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Session:
|
||||
conv_id: str
|
||||
cwd: str
|
||||
# Stable UUID passed to `claude --session-id` so CC owns the history
|
||||
owner_id: str = ""
|
||||
cc_session_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
last_activity: float = field(default_factory=lambda: asyncio.get_event_loop().time())
|
||||
# True after the first message has been sent (so we know to use --resume)
|
||||
last_activity: float = 0.0
|
||||
started: bool = False
|
||||
idle_timeout: int = DEFAULT_IDLE_TIMEOUT
|
||||
cc_timeout: float = DEFAULT_CC_TIMEOUT
|
||||
|
||||
def touch(self) -> None:
|
||||
self.last_activity = asyncio.get_event_loop().time()
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "Session":
|
||||
return cls(**data)
|
||||
|
||||
|
||||
class SessionManager:
|
||||
"""Registry of active Claude Code project sessions."""
|
||||
"""Registry of active Claude Code project sessions with persistence and user isolation."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._sessions: Dict[str, Session] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
self._reaper_task: Optional[asyncio.Task] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def start(self) -> None:
|
||||
self._load()
|
||||
loop = asyncio.get_event_loop()
|
||||
self._reaper_task = loop.create_task(self._reaper_loop())
|
||||
|
||||
@@ -50,62 +60,116 @@ class SessionManager:
|
||||
self._reaper_task.cancel()
|
||||
async with self._lock:
|
||||
self._sessions.clear()
|
||||
if PERSISTENCE_FILE.exists():
|
||||
PERSISTENCE_FILE.unlink()
|
||||
|
||||
async def create(self, conv_id: str, working_dir: str) -> Session:
|
||||
"""Register a new session for the given working directory."""
|
||||
async def create(
|
||||
self,
|
||||
conv_id: str,
|
||||
working_dir: str,
|
||||
owner_id: str = "",
|
||||
idle_timeout: int = DEFAULT_IDLE_TIMEOUT,
|
||||
cc_timeout: float = DEFAULT_CC_TIMEOUT,
|
||||
) -> Session:
|
||||
async with self._lock:
|
||||
session = Session(conv_id=conv_id, cwd=working_dir)
|
||||
session = Session(
|
||||
conv_id=conv_id,
|
||||
cwd=working_dir,
|
||||
owner_id=owner_id,
|
||||
idle_timeout=idle_timeout,
|
||||
cc_timeout=cc_timeout,
|
||||
)
|
||||
self._sessions[conv_id] = session
|
||||
self._save()
|
||||
logger.info(
|
||||
"Created session %s (cc_session_id=%s) in %s",
|
||||
conv_id, session.cc_session_id, working_dir,
|
||||
"Created session %s (owner=...%s) in %s (idle=%ds, cc=%.0fs)",
|
||||
conv_id, owner_id[-8:] if owner_id else "-", working_dir, idle_timeout, cc_timeout,
|
||||
)
|
||||
return session
|
||||
|
||||
async def send(self, conv_id: str, message: str) -> str:
|
||||
"""
|
||||
Run claude -p with the message in the session's directory.
|
||||
|
||||
- First message: uses --session-id <uuid> to establish the CC session.
|
||||
- Subsequent messages: uses --resume <uuid> so CC has full history.
|
||||
"""
|
||||
async def send(self, conv_id: str, message: str, user_id: Optional[str] = None) -> str:
|
||||
async with self._lock:
|
||||
session = self._sessions.get(conv_id)
|
||||
if session is None:
|
||||
raise KeyError(f"No session for conv_id={conv_id!r}")
|
||||
if session.owner_id and user_id and session.owner_id != user_id:
|
||||
raise PermissionError(f"Session {conv_id} belongs to another user")
|
||||
session.touch()
|
||||
cwd = session.cwd
|
||||
cc_session_id = session.cc_session_id
|
||||
cc_timeout = session.cc_timeout
|
||||
first_message = not session.started
|
||||
if first_message:
|
||||
session.started = True
|
||||
self._save()
|
||||
|
||||
output = await run_claude(
|
||||
message,
|
||||
cwd=cwd,
|
||||
cc_session_id=cc_session_id,
|
||||
resume=not first_message,
|
||||
timeout=cc_timeout,
|
||||
)
|
||||
|
||||
log_interaction(
|
||||
conv_id=conv_id,
|
||||
prompt=message,
|
||||
response=output,
|
||||
cwd=cwd,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
return output
|
||||
|
||||
async def close(self, conv_id: str) -> bool:
|
||||
"""Remove a session. Returns True if it existed."""
|
||||
async def close(self, conv_id: str, user_id: Optional[str] = None) -> bool:
|
||||
async with self._lock:
|
||||
if conv_id not in self._sessions:
|
||||
session = self._sessions.get(conv_id)
|
||||
if session is None:
|
||||
return False
|
||||
if session.owner_id and user_id and session.owner_id != user_id:
|
||||
raise PermissionError(f"Session {conv_id} belongs to another user")
|
||||
del self._sessions[conv_id]
|
||||
self._save()
|
||||
logger.info("Closed session %s", conv_id)
|
||||
return True
|
||||
|
||||
def list_sessions(self) -> list[dict]:
|
||||
def list_sessions(self, user_id: Optional[str] = None) -> list[dict]:
|
||||
sessions = self._sessions.values()
|
||||
if user_id:
|
||||
sessions = [s for s in sessions if not s.owner_id or s.owner_id == user_id]
|
||||
return [
|
||||
{"conv_id": s.conv_id, "cwd": s.cwd, "cc_session_id": s.cc_session_id}
|
||||
for s in self._sessions.values()
|
||||
{
|
||||
"conv_id": s.conv_id,
|
||||
"cwd": s.cwd,
|
||||
"owner_id": s.owner_id[-8:] if s.owner_id else None,
|
||||
"cc_session_id": s.cc_session_id,
|
||||
"started": s.started,
|
||||
"idle_timeout": s.idle_timeout,
|
||||
"cc_timeout": s.cc_timeout,
|
||||
}
|
||||
for s in sessions
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
def _save(self) -> None:
|
||||
try:
|
||||
data = {cid: s.to_dict() for cid, s in self._sessions.items()}
|
||||
with open(PERSISTENCE_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
logger.debug("Saved %d sessions to %s", len(data), PERSISTENCE_FILE)
|
||||
except Exception:
|
||||
logger.exception("Failed to save sessions")
|
||||
|
||||
def _load(self) -> None:
|
||||
if not PERSISTENCE_FILE.exists():
|
||||
return
|
||||
try:
|
||||
with open(PERSISTENCE_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
for cid, sdata in data.items():
|
||||
self._sessions[cid] = Session.from_dict(sdata)
|
||||
logger.info("Loaded %d sessions from %s", len(self._sessions), PERSISTENCE_FILE)
|
||||
except Exception:
|
||||
logger.exception("Failed to load sessions")
|
||||
|
||||
async def _reaper_loop(self) -> None:
|
||||
while True:
|
||||
@@ -115,14 +179,15 @@ class SessionManager:
|
||||
async def _reap_idle(self) -> None:
|
||||
now = asyncio.get_event_loop().time()
|
||||
async with self._lock:
|
||||
to_close = [
|
||||
cid for cid, s in self._sessions.items()
|
||||
if (now - s.last_activity) > IDLE_TIMEOUT
|
||||
]
|
||||
to_close = []
|
||||
for cid, s in self._sessions.items():
|
||||
if s.last_activity > 0 and (now - s.last_activity) > s.idle_timeout:
|
||||
to_close.append(cid)
|
||||
for cid in to_close:
|
||||
del self._sessions[cid]
|
||||
logger.info("Reaped idle session %s", cid)
|
||||
if to_close:
|
||||
self._save()
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
manager = SessionManager()
|
||||
|
||||
Reference in New Issue
Block a user