{"repo":"NousResearch/hermes-agent","public_id":"30687","display_label":"Kanban corrupted DB silent data loss fix","summary":"These issues and PRs address a bug where a corrupted or empty Kanban board database would be silently recreated during auto-initialization, causing total data loss. The work focuses on adding guards to refuse or prevent auto-init when database corruption is detected.","subsystem":"hermes_cli","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":30862,"merged_at":"2026-05-23T12:51:34+00:00"},"resolution":{"state":"resolved","by":"pr-30862"},"freshness":{"state":"verified","last_verified_at":"2026-05-23T12:51:34+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":30687,"pr_number":30862,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-23T12:51:34+00:00"}],"members":[{"kind":"issue","number":30687,"title":"Kanban: corrupted board DB + empty top-level DB → silent recreation with total data loss","url":null,"state":"closed"},{"kind":"pr","number":30701,"title":"fix(kanban): guard corrupted board DB auto-init (Fixes #30687)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"c4b8f5efee8cdd3186e465d940bf0e8c98849346","number":0},{"kind":"pr","value":"#30862","number":30862}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":30707,"title":"fix(kanban): refuse corrupt db auto-init","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"c4b8f5efe","number":0},{"kind":"pr","value":"#30862","number":30862}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":30862,"title":"fix(kanban): refuse corrupt db auto-init","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/kanban_db.py","scripts/release.py","tests/hermes_cli/test_kanban_db.py"],"fix_hunks":[{"file":"hermes_cli/kanban_db.py","hunks":["@@ -75,6 +75,7 @@\n import os\n import re\n import secrets\n+import shutil\n import sqlite3\n import subprocess\n import sys","@@ -82,6 +83,7 @@\n import logging\n import time\n from dataclasses import dataclass, field\n+from datetime import datetime\n from pathlib import Path\n from typing import Any, Iterable, Optional\n ","@@ -1005,6 +1007,131 @@ def _validate_sqlite_header(path: Path) -> None:\n     )\n \n \n+class KanbanDbCorruptError(RuntimeError):\n+    \"\"\"Raised when an existing kanban DB file fails integrity checks.\n+\n+    Fail-closed guard against silent recreation of a corrupt board file,\n+    which would otherwise destroy the user's tasks. Carries both the\n+    original path and the timestamped backup we made before refusing.\n+    \"\"\"\n+\n+    def __init__(self, db_path: Path, backup_path: Optional[Path], reason: str):\n+        self.db_path = db_path\n+        self.backup_path = backup_path\n+        self.reason = reason\n+        backup_str = str(backup_path) if backup_path is not None else \"<backup failed>\"\n+        super().__init__(\n+            f\"Refusing to open corrupt kanban DB at {db_path}: {reason}. \"\n+            f\"Original preserved; backup at {backup_str}.\"\n+        )\n+\n+\n+def _backup_corrupt_db(path: Path) -> Optional[Path]:\n+    \"\"\"Copy a corrupt DB (and its WAL/SHM sidecars) to a timestamped backup.\n+\n+    Returns the backup path of the main DB file, or ``None`` if the copy\n+    itself failed (the caller still raises loudly in that case).\n+\n+    Writes are confined to the original DB's parent directory. The\n+    backup basename is derived purely from ``path.name``, never from\n+    caller-supplied directory segments — no traversal is possible.\n+    \"\"\"\n+    # Resolve once and pin the parent so subsequent path operations cannot\n+    # escape it. ``Path.resolve()`` collapses any ``..`` segments and\n+    # symlinks, and we only ever write inside ``parent``.\n+    resolved = path.resolve()\n+    parent = resolved.parent\n+    base_name = resolved.name  # basename only\n+    stamp = datetime.now().strftime(\"%Y%m%d_%H%M%S\")\n+    candidate = parent / f\"{base_name}.corrupt.{stamp}.bak\"\n+    # Defensive: candidate must still be inside parent after construction.\n+    # f-string interpolation of ``base_name`` cannot escape ``parent``\n+    # because ``base_name`` is itself a resolved basename, but assert it\n+    # anyway so static analyzers can see the containment guarantee.\n+    if candidate.parent != parent:\n+        return None\n+    counter = 0\n+    while candidate.exists():\n+        counter += 1\n+        candidate = parent / f\"{base_name}.corrupt.{stamp}.{counter}.bak\"\n+        if candidate.parent != parent:\n+            return None\n+    try:\n+        shutil.copy2(resolved, candidate)\n+    except OSError:\n+        return None\n+    for suffix in (\"-wal\", \"-shm\"):\n+        sidecar = parent / (base_name + suffix)\n+        if sidecar.parent != parent or not sidecar.exists():\n+            continue\n+        try:\n+            sidecar_backup = parent / (candidate.name + suffix)\n+            if sidecar_backup.parent != parent:\n+                continue\n+            shutil.copy2(sidecar, sidecar_backup)\n+        except OSError:\n+            pass\n+    return candidate\n+\n+\n+def _guard_existing_db_is_healthy(path: Path) -> None:\n+    \"\"\"Run ``PRAGMA integrity_check`` on an existing non-empty DB file.\n+\n+    Opens the probe in read/write mode so SQLite can recover or\n+    checkpoint a healthy WAL/hot-journal DB before we declare it\n+    corrupt. If the file is malformed, copy it (and any WAL/SHM\n+    sidecars) to a timestamped backup and raise\n+    :class:`KanbanDbCorruptError` so callers cannot silently recreate\n+    the schema on top of a damaged DB.\n+\n+    Transient lock/busy errors (``sqlite3.OperationalError``) are NOT\n+    treated as corruption; they propagate raw so the caller sees a\n+    normal lock failure and no spurious ``.corrupt`` backup is made.\n+\n+    No-op for missing files, zero-byte files (treated as fresh), and\n+    paths already proven healthy this process (cache hit).\n+\n+    Path-trust note: ``path`` arrives via :func:`connect`, which itself\n+    resolves it from an explicit ``db_path`` argument, the\n+    :func:`kanban_db_path` env-var chain, or the kanban-home default —\n+    all sources Hermes treats as user-controlled-but-trusted on the\n+    user's own machine. We additionally resolve the path here and\n+    confine all filesystem writes to its parent directory so any\n+    accidental ``..`` segments are collapsed before any I/O happens.\n+    \"\"\"\n+    # Resolve before any I/O. ``Path.resolve()`` normalizes ``..`` and\n+    # symlinks, giving us a canonical path whose parent dir we can pin.\n+    try:\n+        resolved = path.resolve()\n+    except OSError:\n+        return\n+    try:\n+        if not resolved.exists() or resolved.stat().st_size == 0:\n+            return\n+    except OSError:\n+        return\n+    if str(resolved) in _INITIALIZED_PATHS:\n+        return\n+    reason: Optional[str] = None\n+    try:\n+        probe = sqlite3.connect(str(resolved), timeout=5, isolation_level=None)\n+        try:\n+            row = probe.execute(\"PRAGMA integrity_check\").fetchone()\n+        finally:\n+            probe.close()\n+        if not row or (row[0] or \"\").lower() != \"ok\":\n+            reason = f\"integrity_check returned {row[0] if row else '<no row>'!r}\"\n+    except sqlite3.OperationalError:\n+        # Lock contention, busy, transient IO — not corruption. Let it propagate.\n+        raise\n+    except sqlite3.DatabaseError as exc:\n+        reason = f\"sqlite refused to open file: {exc}\"\n+    if reason is None:\n+        return\n+    backup = _backup_corrupt_db(resolved)\n+    raise KanbanDbCorruptError(resolved, backup, reason)\n+\n+\n def connect(\n     db_path: Optional[Path] = None,\n     *,","@@ -1033,7 +1160,13 @@ def connect(\n     else:\n         path = kanban_db_path(board=board)\n     path.parent.mkdir(parents=True, exist_ok=True)\n+    # Cheap byte-level check first — catches the #29507 TLS-overwrite shape\n+    # and other invalid-header cases without opening a sqlite connection.\n     _validate_sqlite_header(path)\n+    # Full integrity probe — catches corruption past the header (malformed\n+    # pages, broken internal metadata). Cached per-path after first success\n+    # via _INITIALIZED_PATHS so it only runs once per process per path.\n+    _guard_existing_db_is_healthy(path)\n     resolved = str(path.resolve())\n     conn = sqlite3.connect(str(path), isolation_level=None, timeout=30)\n     try:"]},{"file":"scripts/release.py","hunks":["@@ -808,6 +808,7 @@\n     \"[redacted:email]\": \"xiayh0107\",\n     \"[redacted:email]\": \"opriz\",\n     \"[redacted:email]\": \"xxxigm\",\n+    \"[redacted:email]\": \"NickLarcombe\",\n     \"54813621+xxxigm@users.noreply.github.com\": \"xxxigm\",\n     \"[redacted:email]\": \"anniesurla\",\n     \"[redacted:email]\": \"nv-kasikritc\","]},{"file":"tests/hermes_cli/test_kanban_db.py","hunks":["@@ -2981,3 +2981,104 @@ def test_detect_stale_does_not_tick_failure_counter(kanban_home, monkeypatch):\n         assert \"stale\" in kinds, (\n             f\"Expected 'stale' event in task_events; got {kinds!r}\"\n         )\n+\n+\n+# ---------------------------------------------------------------------------\n+# Corruption guard (issue #30687)\n+# ---------------------------------------------------------------------------\n+\n+def _write_corrupt_db(path: Path) -> bytes:\n+    \"\"\"Write a kanban DB with a VALID SQLite header but malformed page content.\n+\n+    This is the corruption shape the integrity guard specifically targets\n+    (e.g. issue #29507 follow-up reports where the file's first 16 bytes\n+    pass the header byte check but ``PRAGMA integrity_check`` then fails\n+    because the internal pages are damaged). It's what main's header-only\n+    validator was letting through, and what this PR adds the full guard\n+    for.\n+    \"\"\"\n+    # 100-byte SQLite header (magic + minimal valid-looking fields) so the\n+    # cheap header check passes, then deliberate garbage so sqlite refuses\n+    # to read the file past the header.\n+    header = b\"SQLite format 3\\x00\" + b\"\\x10\\x00\\x02\\x02\\x00\\x40\\x20\\x20\"\n+    header += b\"\\x00\\x00\\x00\\x0c\\x00\\x00\\x23\\x46\\x00\\x00\\x00\\x00\"\n+    header = header.ljust(100, b\"\\x00\")\n+    payload = b\"definitely not a valid sqlite page \\x00\\x01\\x02\\x03\" * 64\n+    blob = header + payload\n+    path.write_bytes(blob)\n+    return blob\n+\n+\n+def test_init_db_refuses_corrupt_existing_file(tmp_path):\n+    db_path = tmp_path / \"kanban.db\"\n+    original = _write_corrupt_db(db_path)\n+    # Ensure the cache doesn't mask the guard.\n+    kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))\n+\n+    with pytest.raises(kb.KanbanDbCorruptError) as excinfo:\n+        kb.init_db(db_path=db_path)\n+\n+    err = excinfo.value\n+    assert err.db_path == db_path\n+    assert err.backup_path is not None\n+    assert err.backup_path.exists()\n+    assert err.backup_path.read_bytes() == original\n+    # Original bytes untouched — no schema was written on top.\n+    assert db_path.read_bytes() == original\n+    assert str(db_path) in str(err)\n+    assert str(err.backup_path) in str(err)\n+\n+\n+def test_connect_refuses_corrupt_existing_file(tmp_path):\n+    db_path = tmp_path / \"kanban.db\"\n+    _write_corrupt_db(db_path)\n+    kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))\n+\n+    with pytest.raises(kb.KanbanDbCorruptError):\n+        kb.connect(db_path=db_path)\n+\n+\n+def test_locked_healthy_db_does_not_classify_as_corrupt(tmp_path, monkeypatch):\n+    \"\"\"A transient lock during the probe must not produce a .corrupt backup\n+    and must not be reported as :class:`KanbanDbCorruptError`. Raw sqlite\n+    ``OperationalError`` (lock/busy) is acceptable and expected.\"\"\"\n+    db_path = tmp_path / \"kanban.db\"\n+    kb.init_db(db_path=db_path)\n+    kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))\n+\n+    real_connect = sqlite3.connect\n+\n+    def flaky_connect(*args, **kwargs):\n+        # First call is the integrity probe — simulate a lock.\n+        raise sqlite3.OperationalError(\"database is locked\")\n+\n+    monkeypatch.setattr(kb.sqlite3, \"connect\", flaky_connect)\n+\n+    with pytest.raises(sqlite3.OperationalError):\n+        kb.connect(db_path=db_path)\n+\n+    # No .corrupt backup may be produced for a healthy-but-locked DB.\n+    backups = list(tmp_path.glob(\"*.corrupt.*\"))\n+    assert backups == [], f\"unexpected corrupt backups: {backups}\"\n+\n+    # And once the lock clears, normal access still works.\n+    monkeypatch.setattr(kb.sqlite3, \"connect\", real_connect)\n+    with kb.connect(db_path=db_path) as conn:\n+        kb.create_task(conn, title=\"still here\")\n+        titles = [t.title for t in kb.list_tasks(conn)]\n+    assert \"still here\" in titles\n+\n+\n+def test_init_db_allows_missing_then_healthy(tmp_path):\n+    db_path = tmp_path / \"fresh.db\"\n+    assert not db_path.exists()\n+    kb.init_db(db_path=db_path)\n+    assert db_path.exists() and db_path.stat().st_size > 0\n+\n+    # Idempotent on a healthy DB: data survives a second init.\n+    with kb.connect(db_path=db_path) as conn:\n+        kb.create_task(conn, title=\"keeps\")\n+    kb.init_db(db_path=db_path)\n+    with kb.connect(db_path=db_path) as conn:\n+        tasks = kb.list_tasks(conn)\n+    assert [t.title for t in tasks] == [\"keeps\"]\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}