feat: tool call audit log
Every orchestrator tool invocation is recorded to home/{user}/tool_audit/YYYY-MM-DD.jsonl.
Each entry captures: timestamp, user, tool, args (truncated), status (ok/error/denied),
result length, and a 300-char result snippet.
- tool_audit.py: JSONL writer with per-file asyncio locks; read_recent / read_recent_all_users helpers
- tools/__init__.py: hook in call_tool() — fire-and-forget record on every dispatch
- routers/audit.py: GET /api/audit/recent and /api/audit/stats (admin-only)
- tools/files.py: add home_root() to file_read allowed roots so agents can read audit JSONL
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
83
cortex/routers/audit.py
Normal file
83
cortex/routers/audit.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
Tool audit log endpoints — admin only.
|
||||
|
||||
GET /api/audit/recent?user=scott&days=7&limit=200
|
||||
Returns recent tool call entries for one user (or all users if user omitted).
|
||||
|
||||
GET /api/audit/stats?user=scott&days=7
|
||||
Returns aggregate counts by tool and status.
|
||||
"""
|
||||
import jwt
|
||||
from collections import Counter
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
|
||||
from auth_utils import COOKIE_NAME, decode_token, get_user_role
|
||||
import tool_audit
|
||||
from persona import list_users
|
||||
|
||||
router = APIRouter(prefix="/api/audit")
|
||||
|
||||
|
||||
def _require_admin(request: Request) -> str:
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
try:
|
||||
username = decode_token(token)
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(status_code=401, detail="Invalid session")
|
||||
if get_user_role(username) != "admin":
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return username
|
||||
|
||||
|
||||
@router.get("/recent")
|
||||
async def audit_recent(
|
||||
request: Request,
|
||||
user: str = Query(None, description="Username to filter (omit for all users)"),
|
||||
days: int = Query(7, ge=1, le=90),
|
||||
limit: int = Query(200, ge=1, le=1000),
|
||||
) -> dict:
|
||||
_require_admin(request)
|
||||
|
||||
if user:
|
||||
if user not in list_users():
|
||||
raise HTTPException(status_code=404, detail=f"User not found: {user}")
|
||||
entries = tool_audit.read_recent(user, days=days, limit=limit)
|
||||
else:
|
||||
entries = tool_audit.read_recent_all_users(days=days, limit=limit)
|
||||
|
||||
return {"entries": entries, "count": len(entries), "days": days}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def audit_stats(
|
||||
request: Request,
|
||||
user: str = Query(None),
|
||||
days: int = Query(7, ge=1, le=90),
|
||||
) -> dict:
|
||||
_require_admin(request)
|
||||
|
||||
if user:
|
||||
if user not in list_users():
|
||||
raise HTTPException(status_code=404, detail=f"User not found: {user}")
|
||||
entries = tool_audit.read_recent(user, days=days, limit=10000)
|
||||
else:
|
||||
entries = tool_audit.read_recent_all_users(days=days, limit=10000)
|
||||
|
||||
tool_counts: Counter = Counter()
|
||||
status_counts: Counter = Counter()
|
||||
user_counts: Counter = Counter()
|
||||
|
||||
for e in entries:
|
||||
tool_counts[e.get("tool", "?")] += 1
|
||||
status_counts[e.get("status", "?")] += 1
|
||||
user_counts[e.get("user", "?")] += 1
|
||||
|
||||
return {
|
||||
"total": len(entries),
|
||||
"days": days,
|
||||
"by_tool": dict(tool_counts.most_common()),
|
||||
"by_status": dict(status_counts),
|
||||
"by_user": dict(user_counts.most_common()),
|
||||
}
|
||||
Reference in New Issue
Block a user