feat: record protected generation history

This commit is contained in:
mARTin-B78 2026-09-04 08:07:42 +02:00
parent 98773a4e65
commit d21a6f28e8
7 changed files with 66 additions and 6 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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

View File

@ -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")

14
routes/history.py Normal file
View File

@ -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)}

View File

@ -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 ──────────────────────────────────────────────────────────────

View File

@ -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()