Skip to content

Commit 1935d93

Browse files
committed
Ask AI: a read only question box in the top bar for administrators
An administrator with AI set up gets an Ask AI button in the top bar. It opens a chat panel for plain questions about the data, such as which repositories carry a given CVE, or what a person owns. Ask for a CSV or a PDF and one is made to download. The model is given a map of the tables it may read and how they join, and writes SELECT statements. None of them runs as written. Each is parsed with sqlglot, held to the listed tables and columns (a * only expands to those), stripped of INTO, locking reads, session variables and file or sleep functions, capped, and rebuilt. Password hashes, TOTP secrets, tokens, secret previews, snippets and site sign in details are not on the map, so they cannot be read at all. Nothing is written to the database. Lookups run on a new read only session: the database is told the connection is read only, any statement that is not a SELECT is refused before it goes, the session is rolled back, and the connection is thrown away rather than pooled. These calls skip the AI usage table and the audit log and go to the server log instead. Queries stop after 15 seconds. Files are built from the rows, never from the model's text, with formula cells neutralized, and held in memory for half an hour for the person who asked. Answers are shown as text, links are built on the server from id columns, and each administrator gets 20 questions in 10 minutes.
1 parent 485b56c commit 1935d93

10 files changed

Lines changed: 1275 additions & 3 deletions

File tree

‎app/core/ai.py‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,8 @@ def low_effort(cfg: dict) -> dict:
328328

329329

330330
def complete(cfg: dict, system: str, prompt: str, *, max_tokens: int = None,
331-
timeout: float = None, purpose: str = "other") -> dict:
331+
timeout: float = None, purpose: str = "other",
332+
record: bool = True) -> dict:
332333
"""Ask once. -> {ok, text, error, provider, model, input_tokens,
333334
output_tokens, cache_read_tokens, cache_write_tokens, finish, cut_off,
334335
duration_ms}
@@ -338,7 +339,8 @@ def complete(cfg: dict, system: str, prompt: str, *, max_tokens: int = None,
338339
the caller is a page and a stack trace is not something to show on one.
339340
340341
Every call that actually went out is written to the usage table, under
341-
purpose, good or bad.
342+
purpose, good or bad, unless record is False. The admin question box
343+
passes False because it must never write to the database; it logs instead.
342344
"""
343345
provider = cfg.get("ai.provider") or "disabled"
344346
out = {"ok": False, "text": "", "error": "", "provider": provider,
@@ -369,7 +371,12 @@ def complete(cfg: dict, system: str, prompt: str, *, max_tokens: int = None,
369371
if out["ok"] and not (out.get("text") or "").strip():
370372
out["ok"] = False
371373
out["error"] = "The model answered with nothing."
372-
_record(out, purpose)
374+
if record:
375+
_record(out, purpose)
376+
else:
377+
log.info("AI %s: %s %s ok=%s in=%s out=%s %sms", purpose, provider,
378+
out.get("model") or "", out["ok"], out.get("input_tokens"),
379+
out.get("output_tokens"), out["duration_ms"])
373380
return out
374381

375382

‎app/core/ask.py‎

Lines changed: 768 additions & 0 deletions
Large diffs are not rendered by default.

‎app/core/db.py‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,61 @@ def session_scope():
107107
session.close()
108108

109109

110+
class WriteRefused(Exception):
111+
"""Something other than a SELECT was sent on a read only session."""
112+
113+
114+
def _select_only(conn, cursor, statement, parameters, context, executemany):
115+
if not str(statement or "").lstrip().upper().startswith("SELECT"):
116+
raise WriteRefused("Only SELECT is allowed here.")
117+
118+
119+
@contextlib.contextmanager
120+
def read_only_session(seconds: int = 0):
121+
"""A session that can only SELECT. Nothing is ever committed.
122+
123+
Three locks, so no one of them has to be perfect. The database itself is
124+
told the connection is read only. Every statement is checked before it
125+
goes and anything that is not a SELECT raises. And the session is rolled
126+
back, never committed. The connection is thrown away afterwards rather
127+
than handed back to the pool, so the read only setting cannot leak into
128+
a request that is allowed to write.
129+
"""
130+
from sqlalchemy.orm import Session as OrmSession
131+
132+
conn = engine().connect()
133+
try:
134+
if settings.uses_sqlite:
135+
conn.exec_driver_sql("PRAGMA query_only = ON")
136+
if seconds:
137+
# SQLite has no statement timeout, so a handler that is
138+
# called every few thousand steps stops it instead.
139+
deadline = time.monotonic() + seconds
140+
raw = conn.connection.driver_connection
141+
raw.set_progress_handler(
142+
lambda: 1 if time.monotonic() > deadline else 0, 10000)
143+
else:
144+
conn.exec_driver_sql("SET SESSION TRANSACTION READ ONLY")
145+
if seconds:
146+
try: # MariaDB's name for it. MySQL ignores this.
147+
conn.exec_driver_sql(
148+
f"SET SESSION max_statement_time = {int(seconds)}")
149+
except Exception: # noqa: BLE001
150+
conn.rollback()
151+
conn.rollback()
152+
event.listen(conn, "before_cursor_execute", _select_only)
153+
session = OrmSession(bind=conn, autoflush=False, expire_on_commit=False,
154+
future=True)
155+
try:
156+
yield session
157+
finally:
158+
session.rollback()
159+
session.close()
160+
finally:
161+
conn.invalidate()
162+
conn.close()
163+
164+
110165
def wait_for_database(attempts: int = 30, delay: float = 2.0) -> None:
111166
"""Give the database server time to come up.
112167

‎app/web/routes_api.py‎

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@
99

1010
import logging
1111
import re
12+
import secrets
1213
from datetime import datetime, timedelta, timezone
1314

1415
from fastapi import APIRouter, Depends, Form, HTTPException, Request
1516
from starlette.concurrency import run_in_threadpool
1617

18+
from app.config import settings
1719
from app.core import live, rbac
1820
from app.core.audit import record
1921
from app.core.db import session_scope
@@ -5450,6 +5452,135 @@ async def test_ai(request: Request, user: User = Depends(current_user)):
54505452
f"{result['duration_ms'] / 1000:.1f}s: {result['text'][:80]}"))
54515453

54525454

5455+
def _ask_admin(user: User) -> None:
5456+
"""The question box is for administrators and nobody else.
5457+
5458+
Checked here on every call, not by hiding the button. An administrator
5459+
looking at the site as somebody else is that somebody else here, so they
5460+
are refused too.
5461+
"""
5462+
if (user is None or user.disabled or user.role != UserRole.ADMIN
5463+
or getattr(user, "impersonated_by", "")):
5464+
raise HTTPException(status_code=403, detail="Not allowed")
5465+
5466+
5467+
def ask_available(user: User) -> bool:
5468+
"""Whether to offer the question box: an administrator, with AI set up."""
5469+
if (user is None or user.disabled or user.role != UserRole.ADMIN
5470+
or getattr(user, "impersonated_by", "")):
5471+
return False
5472+
import time
5473+
5474+
now = time.monotonic()
5475+
if now - _AI_READY["at"] < 15:
5476+
return _AI_READY["ready"]
5477+
from app.core import ai
5478+
from app.core.db import read_only_session
5479+
5480+
try:
5481+
with read_only_session() as db:
5482+
cfg = ai.config(db)
5483+
ready = ai.enabled(cfg) and not ai.missing(cfg)
5484+
except Exception: # noqa: BLE001 - a missing button is not worth a 500
5485+
log.warning("could not tell whether AI is set up", exc_info=True)
5486+
ready = False
5487+
_AI_READY.update(at=now, ready=ready)
5488+
return ready
5489+
5490+
5491+
# Whether AI is set up, for a few seconds, so every page does not ask.
5492+
_AI_READY = {"at": -1000.0, "ready": False}
5493+
5494+
5495+
def _ask_pdf(question: str, answer: str, results: list) -> bytes:
5496+
from weasyprint import HTML
5497+
5498+
from app.config import APP_NAME
5499+
from app.core import ask
5500+
from app.web.render import render_partial
5501+
from app.web.routes_pages import report_url_fetcher
5502+
5503+
tables = [{"title": r["title"], "columns": r["columns"],
5504+
"rows": r["rows"][:ask.PDF_ROWS],
5505+
"cut": len(r["rows"]) > ask.PDF_ROWS or r["truncated"]}
5506+
for r in results if not r["error"] and r["columns"]]
5507+
html = render_partial("ask_pdf.html", app_name=APP_NAME, question=question,
5508+
answer=answer, tables=tables,
5509+
now=datetime.now(timezone.utc))
5510+
return HTML(string=html, url_fetcher=report_url_fetcher("")).write_pdf()
5511+
5512+
5513+
@router.post("/ai/ask")
5514+
async def ai_ask(request: Request, user: User = Depends(current_user)):
5515+
"""A plain question about the data. Read only, administrators only.
5516+
5517+
Nothing here writes to the database: the lookups run on a session that
5518+
can only SELECT, and neither the usage table nor the audit log is
5519+
written. The question and the queries go to the server log instead.
5520+
"""
5521+
await check_csrf(request)
5522+
_ask_admin(user)
5523+
5524+
from fastapi.responses import JSONResponse
5525+
5526+
from app.core import ai, ask
5527+
from app.core.db import read_only_session
5528+
5529+
try:
5530+
body = await request.json()
5531+
except Exception: # noqa: BLE001
5532+
body = None
5533+
if not isinstance(body, dict) or not isinstance(body.get("question"), str):
5534+
return JSONResponse({"error": "Send a question."}, status_code=400)
5535+
if not ask.allow(user.username):
5536+
return JSONResponse({"error": "That is a lot of questions. Wait a few "
5537+
"minutes and ask again."}, status_code=429)
5538+
5539+
with read_only_session() as db:
5540+
cfg = ai.config(db)
5541+
if not ai.enabled(cfg) or ai.missing(cfg):
5542+
return JSONResponse({"error": "AI is not set up."}, status_code=400)
5543+
5544+
log.info("ask by %s from %s: %.200r", user.username, client_ip(request),
5545+
body["question"])
5546+
try:
5547+
out = await run_in_threadpool(ask.ask, cfg, user, body["question"],
5548+
body.get("history"), _ask_pdf)
5549+
except ask.AskError as exc:
5550+
return JSONResponse({"error": str(exc)}, status_code=400)
5551+
except Exception: # noqa: BLE001 - never a stack trace on the page
5552+
ref = secrets.token_hex(4)
5553+
log.exception("ask failed, reference %s", ref)
5554+
return JSONResponse({"error": f"Something went wrong. Reference {ref}."},
5555+
status_code=500)
5556+
for q in out["queries"]:
5557+
log.info("ask by %s ran: %.500s %s", user.username, q["sql"],
5558+
q["error"] or "ok")
5559+
for item in out["files"]:
5560+
item["href"] = f"{settings.base_path}/ai/ask/file/{item.pop('token')}"
5561+
out["links"] = [{"label": link["label"],
5562+
"href": f"{settings.base_path}{link['href']}"}
5563+
for link in out["links"]]
5564+
return JSONResponse(out, headers={"Cache-Control": "no-store"})
5565+
5566+
5567+
@router.get("/ai/ask/file/{token}")
5568+
def ai_ask_file(token: str, user: User = Depends(current_user)):
5569+
"""A CSV or PDF made for an answer, to the person who asked, for half an hour."""
5570+
_ask_admin(user)
5571+
5572+
from fastapi.responses import Response
5573+
5574+
from app.core import ask
5575+
5576+
item = ask.take_file(user.username, token)
5577+
if item is None:
5578+
raise HTTPException(status_code=404, detail="That file is gone. Ask again.")
5579+
return Response(content=item["body"], media_type=item["media"], headers={
5580+
"Content-Disposition": 'attachment; filename="%s"' % item["name"],
5581+
"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"})
5582+
5583+
54535584
@router.post("/ai/models")
54545585
async def ai_models(request: Request, user: User = Depends(current_user)):
54555586
"""The models a provider offers the saved key, for the picker."""

‎app/web/routes_pages.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,9 +237,18 @@ def _shared(db, user: User) -> dict:
237237
"external_on": _external_on(db),
238238
"can_see_external": _external_on(db) and rbac.sees_external(db, user),
239239
"can_manage_external": _external_on(db) and rbac.can(user, "external.manage"),
240+
# The question box in the top bar. Administrators with AI set up only;
241+
# the route checks again on every question.
242+
"ask_ai": _ask_available(user),
240243
}
241244

242245

246+
def _ask_available(user) -> bool:
247+
from app.web.routes_api import ask_available
248+
249+
return ask_available(user)
250+
251+
243252
def _external_on(db) -> bool:
244253
from app.core import external
245254

‎app/web/static/app.css‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1217,3 +1217,55 @@ tr.decided-out > td.decision-cell { opacity: 1; }
12171217
.rec-actions { display: flex; gap: .8rem; align-items: center; flex-wrap: wrap; }
12181218
.rec-actions form { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; margin: 0; }
12191219
.rec-steps { margin: 0 0 .4rem 1.2rem; padding: 0; }
1220+
1221+
/* ---- Ask AI, the question box in the top bar --------------------------- */
1222+
.ask-toggle {
1223+
display: inline-flex; align-items: center; gap: .35rem;
1224+
background: rgba(255,255,255,.1); color: var(--text-invert);
1225+
border: 1px solid rgba(255,255,255,.25); border-radius: 999px;
1226+
padding: .25rem .7rem; font: inherit; font-size: .85rem; cursor: pointer;
1227+
}
1228+
.ask-toggle:hover, .ask-toggle[aria-expanded="true"] { background: rgba(255,255,255,.2); }
1229+
.ask-toggle svg { width: 14px; height: 14px; }
1230+
.ask-panel {
1231+
position: fixed; top: calc(var(--topbar-height) + 8px); right: 12px; z-index: 60;
1232+
width: min(30rem, calc(100vw - 24px)); height: min(36rem, calc(100vh - var(--topbar-height) - 24px));
1233+
display: flex; flex-direction: column;
1234+
background: var(--bg); color: var(--text);
1235+
border: 1px solid var(--border-strong); border-radius: var(--radius);
1236+
box-shadow: 0 10px 30px rgba(0,0,0,.25);
1237+
}
1238+
.ask-panel[hidden] { display: none; }
1239+
.ask-head {
1240+
display: flex; align-items: center; gap: .6rem; padding: .6rem .8rem;
1241+
border-bottom: 1px solid var(--border);
1242+
}
1243+
.ask-head .faint { flex: 1; }
1244+
.ask-clear, .ask-close {
1245+
background: none; border: 0; color: var(--text-soft); cursor: pointer; font: inherit;
1246+
}
1247+
.ask-close { font-size: 1.3rem; line-height: 1; }
1248+
.ask-log { flex: 1; overflow-y: auto; padding: .8rem; display: flex; flex-direction: column; gap: .6rem; }
1249+
.ask-msg {
1250+
max-width: 92%; padding: .55rem .75rem; border-radius: 10px;
1251+
white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.45; font-size: .9rem;
1252+
}
1253+
.ask-msg.user { align-self: flex-end; background: var(--header); color: var(--text-invert); }
1254+
.ask-msg.bot { align-self: flex-start; background: var(--bg-soft); border: 1px solid var(--border); }
1255+
.ask-msg.pending { color: var(--text-faint); font-style: italic; }
1256+
.ask-msg.error { background: var(--bad-bg); color: var(--bad); border-color: transparent; }
1257+
.ask-files, .ask-links { display: flex; flex-wrap: wrap; gap: .4rem .8rem; margin-top: .5rem; white-space: normal; }
1258+
.ask-links a { font-size: .85rem; }
1259+
.ask-table, .ask-sql { margin-top: .5rem; white-space: normal; }
1260+
.ask-table summary, .ask-sql summary { cursor: pointer; font-size: .82rem; color: var(--text-soft); }
1261+
.ask-table-scroll { max-height: 14rem; overflow: auto; margin-top: .3rem; }
1262+
.ask-table table { font-size: .78rem; }
1263+
.ask-table td, .ask-table th { white-space: nowrap; padding: .2rem .45rem; }
1264+
.ask-sql pre { white-space: pre-wrap; font-size: .75rem; margin: .2rem 0 .5rem; }
1265+
.ask-form { display: flex; gap: .5rem; padding: .6rem .8rem; border-top: 1px solid var(--border); align-items: flex-end; }
1266+
.ask-form textarea {
1267+
flex: 1; resize: none; font: inherit; font-size: .9rem; padding: .4rem .5rem;
1268+
border: 1px solid var(--border-strong); border-radius: var(--radius);
1269+
background: var(--bg); color: var(--text);
1270+
}
1271+
@media (max-width: 640px) { .ask-toggle span { display: none; } }

0 commit comments

Comments
 (0)