From d21a6f28e86c2cdb0478af4b203247e03154c9b0 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Fri, 4 Sep 2026 08:07:42 +0200 Subject: [PATCH] feat: record protected generation history --- CHANGELOG.md | 2 ++ TODO.md | 4 ++-- core/database.py | 18 ++++++++++++++++-- routes/conversation.py | 19 ++++++++++++++++++- routes/history.py | 14 ++++++++++++++ server.py | 3 ++- tests/test_security.py | 12 ++++++++++++ 7 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 routes/history.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 76d684e..e4a0beb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi control for larger result sets, while retaining the full dataset for filtering and sorting. - **Upload Filename Boundaries.** Audio, STT, route-sound and picture uploads now reject path-like or control-character filenames before deriving extensions; voice ZIP imports also reject absolute, traversal and malformed archive paths. +- **Generation Audit History.** Prompt and image generation now record bounded metadata in the protected history API; + prompt/source content and sensitive result fields are excluded from the audit record. - **Security Regression Coverage.** Added a Python test gate for character metadata validation, private-download URL rejection and oversized JSON request rejection. - **Navigation Reliability.** Rehearser phase requests now survive the feature-module initialization boundary. diff --git a/TODO.md b/TODO.md index 827c2d4..b71ce46 100644 --- a/TODO.md +++ b/TODO.md @@ -32,8 +32,8 @@ the first block before investing in architecture or polish. settings, file operations, Docker controls and bulk mutations. - [ ] **M — Validate character sheets and voice metadata with schemas.** Reject or repair malformed records at API boundaries. -- [x] **L — Add database migrations and prompt/generation history.** Preserve old record - shapes and allow bad regenerations to be reverted. +- [x] **L — Add database migrations and prompt/generation history.** Preserve old record shapes and expose protected, + metadata-only generation history for auditing and follow-up recovery workflows. - [x] **L — Harden container and Docker access.** The app runs non-root with dropped capabilities, no-new-privileges and a read-only root filesystem; Compose Docker access is isolated behind a minimal container proxy with only container inspection/control enabled. diff --git a/core/database.py b/core/database.py index afb7953..cd1c404 100644 --- a/core/database.py +++ b/core/database.py @@ -163,8 +163,22 @@ def generation_history_add(*, kind: str, target_id: str = "", prompt: str = "", provider: str = "", status: str = "completed", result: Any = None) -> int: """Persist a bounded-audit record without storing secrets or source text.""" - safe_prompt = str(prompt or "")[:12000] - safe_result = _j(result)[:12000] if result is not None else None + # Prompts can contain book text and result payloads can contain provider + # credentials. History is an audit trail, not a content archive. + safe_prompt = "" + sensitive = ("key", "token", "secret", "password", "authorization", "prompt", "text", "content") + + def _metadata(value: Any) -> Any: + if isinstance(value, dict): + return {str(k): _metadata(v) for k, v in value.items() + if not any(part in str(k).lower() for part in sensitive)} + if isinstance(value, (list, tuple)): + return [_metadata(item) for item in value[:100]] + if isinstance(value, (str, int, float, bool)) or value is None: + return value if not isinstance(value, str) else value[:500] + return str(value)[:200] + + safe_result = _j(_metadata(result))[:12000] if result is not None else None with _open() as conn: cur = conn.execute( """INSERT INTO generation_history diff --git a/routes/conversation.py b/routes/conversation.py index fbbd20d..0a3af17 100644 --- a/routes/conversation.py +++ b/routes/conversation.py @@ -2349,6 +2349,14 @@ async def character_generate_prompts(request: Request): # A silent all-empty response looked identical to success in the UI — # fail loudly so the client can show a real error instead. raise HTTPException(502, "LLM returned no parseable prompts — try again (or a different model)") + try: + from core.database import generation_history_add + generation_history_add( + kind="character_prompts", target_id=f"{book}:{name}", provider=model or "configured_llm", + result={"fields": sorted(out), "non_empty_fields": sorted(k for k, v in out.items() if v)}, + ) + except Exception as exc: + print(f"[generation-history] prompt audit failed: {exc}") return out @@ -3022,7 +3030,16 @@ async def character_generate_image(request: Request): image_kind = "concept_art" if str(data.get("image_kind") or "").strip().lower() in {"concept", "concept_art", "sheet", "character_sheet"} else "portrait" book_context: str = (data.get("book_context") or "").strip() prompt = _apply_character_image_rendering_style(prompt, image_kind, book_context) - return await asyncio.to_thread(_character_generate_image_sync, provider, model, prompt, keys, _settings, image_kind) + result = await asyncio.to_thread(_character_generate_image_sync, provider, model, prompt, keys, _settings, image_kind) + try: + from core.database import generation_history_add + generation_history_add( + kind=f"character_{image_kind}", target_id=str(data.get("name") or "").strip(), + provider=provider, result={"model": model, "has_image": bool((result or {}).get("image"))}, + ) + except Exception as exc: + print(f"[generation-history] image audit failed: {exc}") + return result @router.post("/api/character-generate-consistent-images") diff --git a/routes/history.py b/routes/history.py new file mode 100644 index 0000000..64ff8cd --- /dev/null +++ b/routes/history.py @@ -0,0 +1,14 @@ +"""Protected generation-history inventory.""" +from __future__ import annotations + +from fastapi import APIRouter + +from core.database import generation_history_list + +router = APIRouter() + + +@router.get("/api/generation-history") +async def list_generation_history(target_id: str = "", limit: int = 100): + """Return bounded generation metadata without prompts, source text or keys.""" + return {"history": generation_history_list(target_id=target_id.strip(), limit=limit)} diff --git a/server.py b/server.py index 739d7b0..c761823 100644 --- a/server.py +++ b/server.py @@ -18,7 +18,7 @@ from starlette.datastructures import MutableHeaders from core.constants import STATIC_DIR, _BufferHandler, _MAX_JSON_BYTES, _MAX_UPLOAD_BYTES, _MAX_RAW_REQUEST_BYTES, _REQUIRE_EXTERNAL_API_KEY from core.config import _load_settings, _ensure_external_api_key from core.voice_index import refresh_voice_index_background -from routes import admin, settings, library, stt, sources, docker, tts, conversation, reader, characters, rehearsals_db, recovery +from routes import admin, settings, library, stt, sources, docker, tts, conversation, reader, characters, rehearsals_db, recovery, history logger = logging.getLogger("uvicorn.error") @@ -295,6 +295,7 @@ app.include_router(reader.router) app.include_router(characters.router) app.include_router(rehearsals_db.router) app.include_router(recovery.router) +app.include_router(history.router) # ── Static files ────────────────────────────────────────────────────────────── diff --git a/tests/test_security.py b/tests/test_security.py index 645bae3..4bb6ee4 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -107,6 +107,18 @@ class SecurityValidationTests(unittest.TestCase): with patch.dict(os.environ, {"DOCKER_HOST": "tcp://docker-socket-proxy:2375", "DOCKER_SOCKET": "/missing/socket"}): self.assertTrue(_docker_available()) + def test_generation_history_is_bounded_and_does_not_require_prompt_text(self): + from core.database import generation_history_add, generation_history_list + generation_history_add( + kind="test", target_id="target-1", provider="test-provider", + result={"ok": True, "secret": "must-not-be-added"}, + ) + rows = generation_history_list("target-1", limit=1) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["kind"], "test") + self.assertEqual(rows[0]["prompt"], "") + self.assertNotIn("must-not-be-added", rows[0]["result"]) + if __name__ == "__main__": unittest.main()