From 02accefe8fb679608cec816878671f4c479274d8 Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Tue, 5 May 2026 20:36:08 -0400 Subject: [PATCH] feat: audit log in Files panel sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an "Audit Log" section (collapsed by default) at the bottom of the Files panel showing tool_audit/YYYY-MM-DD.jsonl files for the current user. - GET /api/audit/files — lists available dates (newest first, any auth user) - GET /api/audit/day — returns entries for one date as JSON (any auth user) - tool_audit.read_day() — reads a single day's JSONL file chronologically - Clicking a date renders a read-only table: time / tool / status / args / result - Status cells are colour-coded (green ok, red error, amber denied) - Edit/Raw/Preview/Save buttons are hidden in audit view, restored on file switch - Audit group starts collapsed; expands on click like other file groups Co-Authored-By: Claude Sonnet 4.6 --- cortex/routers/audit.py | 51 ++++++++++++-- cortex/static/app.js | 149 +++++++++++++++++++++++++++++++++++----- cortex/static/style.css | 36 ++++++++++ cortex/tool_audit.py | 20 ++++++ 4 files changed, 232 insertions(+), 24 deletions(-) diff --git a/cortex/routers/audit.py b/cortex/routers/audit.py index 55bfb4f..7518e65 100644 --- a/cortex/routers/audit.py +++ b/cortex/routers/audit.py @@ -1,36 +1,75 @@ """ -Tool audit log endpoints — admin only. +Tool audit log endpoints. +Self-service (any authenticated user, own data): + GET /api/audit/files → list of available date strings (newest first) + GET /api/audit/day?date=YYYY-MM-DD → entries for one day + +Admin-only (cross-user aggregation): 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 datetime import date, timedelta from fastapi import APIRouter, HTTPException, Query, Request from auth_utils import COOKIE_NAME, decode_token, get_user_role +from config import settings import tool_audit from persona import list_users router = APIRouter(prefix="/api/audit") -def _require_admin(request: Request) -> str: +def _session_user(request: Request) -> str: + """Return the authenticated username or raise 401.""" token = request.cookies.get(COOKIE_NAME) if not token: raise HTTPException(status_code=401, detail="Not authenticated") try: - username = decode_token(token) + return decode_token(token) except jwt.InvalidTokenError: raise HTTPException(status_code=401, detail="Invalid session") + + +def _require_admin(request: Request) -> str: + username = _session_user(request) if get_user_role(username) != "admin": raise HTTPException(status_code=403, detail="Admin access required") return username +@router.get("/files") +async def audit_files(request: Request) -> dict: + """List available audit log dates for the current user (newest first).""" + username = _session_user(request) + audit_dir = settings.home_root() / username / "tool_audit" + if not audit_dir.exists(): + return {"dates": []} + dates = sorted( + [p.stem for p in audit_dir.glob("*.jsonl") if p.stem], + reverse=True, + ) + return {"dates": dates} + + +@router.get("/day") +async def audit_day( + request: Request, + date: str = Query(..., description="YYYY-MM-DD"), +) -> dict: + """Return all entries for a specific day (current user only).""" + username = _session_user(request) + try: + from datetime import date as _date + d = _date.fromisoformat(date) + except ValueError: + raise HTTPException(status_code=400, detail="date must be YYYY-MM-DD") + entries = tool_audit.read_day(username, date) + return {"date": date, "entries": entries} + + @router.get("/recent") async def audit_recent( request: Request, diff --git a/cortex/static/app.js b/cortex/static/app.js index eb162d9..996352f 100644 --- a/cortex/static/app.js +++ b/cortex/static/app.js @@ -1375,22 +1375,26 @@ return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } - function renderFileSidebar(files) { + function _makeFileGroup(label, collapsed = false) { + const groupEl = document.createElement('div'); + groupEl.className = 'file-group'; + const header = document.createElement('div'); + header.className = 'fg-header' + (collapsed ? ' collapsed' : ''); + header.textContent = label; + header.addEventListener('click', () => header.classList.toggle('collapsed')); + groupEl.appendChild(header); + const items = document.createElement('div'); + items.className = 'fg-items'; + groupEl.appendChild(items); + return { groupEl, items }; + } + + function renderFileSidebar(files, auditDates = []) { const byName = Object.fromEntries(files.map(f => [f.name, f])); fileSidebar.innerHTML = ''; for (const group of FILE_GROUPS) { - const groupEl = document.createElement('div'); - groupEl.className = 'file-group'; - - const header = document.createElement('div'); - header.className = 'fg-header'; - header.textContent = group.label; - header.addEventListener('click', () => header.classList.toggle('collapsed')); - groupEl.appendChild(header); - - const items = document.createElement('div'); - items.className = 'fg-items'; + const { groupEl, items } = _makeFileGroup(group.label); for (const fname of group.files) { const f = byName[fname]; @@ -1416,7 +1420,35 @@ items.appendChild(item); } - groupEl.appendChild(items); + fileSidebar.appendChild(groupEl); + } + + // ── Audit Log section (dynamic, date-named files) ────────── + if (auditDates.length > 0) { + const { groupEl, items } = _makeFileGroup('Audit Log', true); + + for (const d of auditDates) { + const item = document.createElement('div'); + item.className = 'file-item'; + item.dataset.name = 'audit:' + d; + if (activeFileName === 'audit:' + d) item.classList.add('active'); + + const nameEl = document.createElement('div'); + nameEl.className = 'fi-name'; + nameEl.textContent = d + '.jsonl'; + item.appendChild(nameEl); + + const metaEl = document.createElement('div'); + metaEl.className = 'fi-meta'; + const today = new Date().toISOString().slice(0, 10); + const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10); + metaEl.innerHTML = `${d === today ? 'today' : d === yesterday ? 'yesterday' : d}`; + item.appendChild(metaEl); + + item.addEventListener('click', () => loadAuditLog(d)); + items.appendChild(item); + } + fileSidebar.appendChild(groupEl); } } @@ -1455,6 +1487,10 @@ async function loadFile(name) { setActiveFile(name); initMdEditor(); + // Restore editor/preview buttons hidden by audit view + fileRawBtn.style.display = ''; + filePreviewBtn.style.display = ''; + fileSaveBtn.style.display = ''; const res = await fetch(`/files/${encodeURIComponent(name)}?${_fileParams}`); if (!res.ok) { mdEditor.setValue(`Error loading ${name}`); return; } const data = await res.json(); @@ -1463,13 +1499,90 @@ setFileMode(fileMode); } - async function openFileModal() { - const res = await fetch(`/files?${_fileParams}`); + function _auditStatusClass(status) { + if (status === 'ok') return 'at-status ok'; + if (status === 'error') return 'at-status error'; + if (status === 'denied') return 'at-status denied'; + return 'at-status'; + } + + function _fmtArgs(args) { + if (!args || typeof args !== 'object') return ''; + return Object.entries(args) + .map(([k, v]) => { + const s = typeof v === 'string' ? v : JSON.stringify(v); + return `${k}: ${s.length > 60 ? s.slice(0, 60) + '…' : s}`; + }) + .join(' · '); + } + + async function loadAuditLog(dateStr) { + setActiveFile('audit:' + dateStr); + document.getElementById('file-modal-title').textContent = dateStr + '.jsonl'; + // Hide edit controls — audit logs are read-only + fileRawBtn.style.display = 'none'; + filePreviewBtn.style.display = 'none'; + fileSaveBtn.style.display = 'none'; + fileEditorWrap.classList.add('hidden'); + filePreview.classList.add('active'); + filePreview.style.display = ''; + filePreview.innerHTML = '
Loading…
'; + + const res = await fetch(`/api/audit/day?date=${encodeURIComponent(dateStr)}`); + if (!res.ok) { + filePreview.innerHTML = '
Failed to load audit log.
'; + return; + } const data = await res.json(); - renderFileSidebar(data.files); + const entries = data.entries || []; + + if (entries.length === 0) { + filePreview.innerHTML = '
No entries for this date.
'; + return; + } + + const table = document.createElement('table'); + table.className = 'audit-table'; + table.innerHTML = ` + Time + Tool + Status + Args + Result + `; + + const tbody = document.createElement('tbody'); + for (const e of entries) { + const time = (e.ts || '').slice(11, 19); // HH:MM:SS + const tr = document.createElement('tr'); + tr.innerHTML = ` + ${time} + ${e.tool || '?'} + ${e.status || '?'} + ${_fmtArgs(e.args)} + ${ + (e.result_snippet || '').replace(/ 80 ? `… [${e.result_chars} chars]` : '') + }`; + tbody.appendChild(tr); + } + table.appendChild(tbody); + filePreview.innerHTML = ''; + filePreview.appendChild(table); + } + + async function openFileModal() { + const [filesRes, auditRes] = await Promise.all([ + fetch(`/files?${_fileParams}`), + fetch('/api/audit/files'), + ]); + const filesData = await filesRes.json(); + const auditData = auditRes.ok ? await auditRes.json() : { dates: [] }; + + renderFileSidebar(filesData.files, auditData.dates); fileModal.classList.add('open'); - // Load first existing file - const first = data.files.find(f => f.exists) || data.files[0]; + // Load first existing regular file + const first = filesData.files.find(f => f.exists) || filesData.files[0]; if (first) await loadFile(first.name); } diff --git a/cortex/static/style.css b/cortex/static/style.css index 2b5d6b2..64feffa 100644 --- a/cortex/static/style.css +++ b/cortex/static/style.css @@ -1232,6 +1232,42 @@ #file-preview.active { display: block; } #file-editor-wrap.hidden { display: none; } + /* ── Audit log table ────────────────────────────────────────── */ + .audit-table { + width: 100%; + border-collapse: collapse; + font-size: 0.78rem; + table-layout: fixed; + } + .audit-table th { + text-align: left; + padding: 5px 8px; + border-bottom: 1px solid var(--border); + color: var(--muted); + font-weight: 600; + white-space: nowrap; + } + .audit-table td { + padding: 5px 8px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 50%, transparent); + vertical-align: top; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .audit-table tr:last-child td { border-bottom: none; } + .audit-table tr:hover td { background: var(--surface); } + /* Column widths */ + .at-time { width: 7em; color: var(--muted); white-space: nowrap; } + .at-tool { width: 11em; color: var(--accent); font-weight: 500; } + .at-status { width: 4.5em; font-weight: 600; } + .at-args { width: 30%; color: var(--muted); } + .at-result { color: var(--muted); } + .at-status.ok { color: #4ade80; } + .at-status.error { color: #f87171; } + .at-status.denied { color: #fbbf24; } + .audit-empty { padding: 24px; color: var(--muted); text-align: center; font-size: 0.9rem; } + /* Talk activity badge on Sessions button */ #sessions-btn.talk-badge::after { content: '●'; diff --git a/cortex/tool_audit.py b/cortex/tool_audit.py index a824e90..7b428a7 100644 --- a/cortex/tool_audit.py +++ b/cortex/tool_audit.py @@ -113,6 +113,26 @@ def read_recent(user: str, days: int = 7, limit: int = 200) -> list[dict]: return entries[:limit] +def read_day(user: str, day_str: str) -> list[dict]: + """Read all entries for a specific date string (YYYY-MM-DD), chronological order.""" + path = settings.home_root() / user / "tool_audit" / f"{day_str}.jsonl" + if not path.exists(): + return [] + entries = [] + try: + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + try: + entries.append(json.loads(line)) + except json.JSONDecodeError: + pass + except Exception: + pass + return entries + + def read_recent_all_users(days: int = 7, limit: int = 500) -> list[dict]: """Read recent entries across all users, sorted newest-first.""" from persona import list_users