Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
"""#100368 regression: the corruption guidance must not direct a WAL-reset-
|
||||
vulnerable sqlite3 CLI at a live Hermes database.
|
||||
|
||||
Field forensics (issue #100368, maintainer round 2 + the isolated reproducer
|
||||
in its comments): when a shell with SQLite's WAL-reset opener bug (fixed
|
||||
3.51.3+ / backports 3.50.7 / 3.44.6 — Debian/Ubuntu system CLIs 3.45.1 /
|
||||
3.46.1 are in the vulnerable band) opens a live state.db whose writer's DMS
|
||||
lock has been cancelled, it unlinks the live -wal/-shm pair and splits the
|
||||
store into two concurrent generations. Both generations report
|
||||
``integrity_check ok`` while an old-generation acknowledged write is lost.
|
||||
|
||||
Hermes' own corruption banners used to instruct exactly that command
|
||||
(`sqlite3 ~/.hermes/state.db ".recover"`). The fix routes operators to
|
||||
`hermes sessions recover --source ...`, whose lane snapshots the damaged
|
||||
bundle before any shell touches it, and refuses a WAL-reset-vulnerable
|
||||
sqlite3 CLI for the page-level salvage lane even on the snapshot.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import inspect
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.session_lost_and_found import (
|
||||
_parse_sqlite3_cli_version,
|
||||
_wal_reset_vulnerable,
|
||||
find_sqlite3_cli,
|
||||
find_sqlite3_cli_refusal,
|
||||
)
|
||||
from hermes_cli.sqlite_runtime import is_sqlite_wal_reset_vulnerable
|
||||
|
||||
|
||||
LIVE_DB_SALVAGE_COMMAND = 'sqlite3 ~/.hermes/state.db ".recover"'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The version gate itself
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWalResetVersionGate:
|
||||
@pytest.mark.parametrize(
|
||||
"version",
|
||||
[(3, 45, 1), (3, 46, 1), (3, 44, 5), (3, 50, 4), (3, 51, 2), (3, 8, 0)],
|
||||
)
|
||||
def test_vulnerable_versions(self, version):
|
||||
assert _wal_reset_vulnerable(version) is True
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"version",
|
||||
[
|
||||
(3, 44, 6),
|
||||
(3, 44, 7),
|
||||
(3, 50, 7),
|
||||
(3, 50, 8),
|
||||
(3, 51, 3),
|
||||
(3, 51, 4),
|
||||
(3, 52, 0),
|
||||
(3, 53, 1),
|
||||
(4, 0, 0),
|
||||
],
|
||||
)
|
||||
def test_fixed_versions(self, version):
|
||||
assert _wal_reset_vulnerable(version) is False
|
||||
|
||||
def test_gate_mirrors_library_gate(self):
|
||||
"""The salvage gate must agree with the shared runtime gate so the
|
||||
embedded library and the salvage shell can never disagree."""
|
||||
versions = [
|
||||
(3, 44, 5),
|
||||
(3, 44, 6),
|
||||
(3, 45, 1),
|
||||
(3, 50, 4),
|
||||
(3, 50, 7),
|
||||
(3, 51, 2),
|
||||
(3, 51, 3),
|
||||
(3, 53, 1),
|
||||
]
|
||||
for version in versions:
|
||||
assert _wal_reset_vulnerable(version) == (
|
||||
is_sqlite_wal_reset_vulnerable(version)
|
||||
), f"salvage gate disagrees with the runtime gate at {version}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# find_sqlite3_cli refuses unsafe shells and explains why
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFindSqlite3CliRefusal:
|
||||
def test_missing_binary_refusal(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.session_lost_and_found.shutil.which", lambda _: None
|
||||
)
|
||||
assert find_sqlite3_cli() is None
|
||||
assert find_sqlite3_cli_refusal()["reason"] == "missing"
|
||||
|
||||
def test_no_dbpage_refusal(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.session_lost_and_found.shutil.which",
|
||||
lambda _: "/usr/bin/sqlite3",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.session_lost_and_found._cli_supports_recover",
|
||||
lambda _: False,
|
||||
)
|
||||
assert find_sqlite3_cli() is None
|
||||
assert find_sqlite3_cli_refusal()["reason"] == "no_dbpage"
|
||||
|
||||
def test_wal_reset_vulnerable_refusal(self, monkeypatch):
|
||||
"""A .recover-capable but WAL-reset-vulnerable CLI must be refused.
|
||||
|
||||
This is the Debian/Ubuntu shape from the #100368 incident: the
|
||||
system sqlite3 (3.45.1) has sqlite_dbpage, so the capability probe
|
||||
passes, while the WAL-reset opener bug is still present.
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.session_lost_and_found.shutil.which",
|
||||
lambda _: "/usr/bin/sqlite3",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.session_lost_and_found._cli_supports_recover",
|
||||
lambda _: True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.session_lost_and_found._parse_sqlite3_cli_version",
|
||||
lambda _: (3, 45, 1),
|
||||
)
|
||||
assert find_sqlite3_cli() is None
|
||||
refusal = find_sqlite3_cli_refusal()
|
||||
assert refusal["reason"] == "wal_reset_vulnerable"
|
||||
assert refusal["version"] == "3.45.1"
|
||||
assert "WAL-reset" in refusal["detail"]
|
||||
|
||||
def test_fixed_capable_cli_accepted(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.session_lost_and_found.shutil.which",
|
||||
lambda _: "/usr/local/bin/sqlite3",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.session_lost_and_found._cli_supports_recover",
|
||||
lambda _: True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.session_lost_and_found._parse_sqlite3_cli_version",
|
||||
lambda _: (3, 51, 3),
|
||||
)
|
||||
assert find_sqlite3_cli() == "/usr/local/bin/sqlite3"
|
||||
assert find_sqlite3_cli_refusal() == {}
|
||||
|
||||
def test_unparsable_version_still_usable(self, monkeypatch):
|
||||
"""A CLI whose version line cannot be parsed is not refused on
|
||||
version grounds alone (the salvage lane runs against a snapshot
|
||||
copy, not the live file)."""
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.session_lost_and_found.shutil.which",
|
||||
lambda _: "/usr/bin/sqlite3",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.session_lost_and_found._cli_supports_recover",
|
||||
lambda _: True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.session_lost_and_found._parse_sqlite3_cli_version",
|
||||
lambda _: None,
|
||||
)
|
||||
assert find_sqlite3_cli() == "/usr/bin/sqlite3"
|
||||
|
||||
|
||||
class TestParseSqlite3CliVersion:
|
||||
def test_parses_modern_output(self):
|
||||
class Probe:
|
||||
returncode = 0
|
||||
stdout = b"3.51.4 2026-XX-XX 12:34:56\n"
|
||||
|
||||
with patch(
|
||||
"hermes_cli.session_lost_and_found.subprocess.run",
|
||||
return_value=Probe(),
|
||||
):
|
||||
assert _parse_sqlite3_cli_version("x") == (3, 51, 4)
|
||||
|
||||
def test_unexecutable_returns_none(self):
|
||||
with patch(
|
||||
"hermes_cli.session_lost_and_found.subprocess.run",
|
||||
side_effect=OSError("no such file"),
|
||||
):
|
||||
assert _parse_sqlite3_cli_version("x") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The operator-facing guidance never names the live DB
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGuidanceNeverNamesLiveDb:
|
||||
def test_gateway_corruption_banner(self):
|
||||
"""The gateway broadcast must route to the two-stage `sessions
|
||||
recover` contract and must warn against pointing a raw sqlite3
|
||||
shell at the live file."""
|
||||
import gateway.run as gateway_run
|
||||
|
||||
body = inspect.getsource(
|
||||
gateway_run.GatewayRunner._send_session_db_warning_notifications
|
||||
)
|
||||
assert LIVE_DB_SALVAGE_COMMAND not in body
|
||||
assert "sessions recover --source" in body
|
||||
assert "--inspect-only" in body
|
||||
assert "--output" in body
|
||||
assert "do NOT" in body
|
||||
|
||||
def test_run_agent_corrupt_explanation(self):
|
||||
from run_agent import AIAgent
|
||||
|
||||
explanation = AIAgent._format_turn_completion_explanation(
|
||||
"session_persistence_failed", "corrupt"
|
||||
)
|
||||
assert LIVE_DB_SALVAGE_COMMAND not in explanation
|
||||
assert "hermes sessions recover --source" in explanation
|
||||
assert "--inspect-only" in explanation
|
||||
assert "--output recovered-state.db" in explanation
|
||||
assert ".recover" in explanation # the warning still names the hazard
|
||||
|
||||
def test_repair_budget_error_names_safe_lane(self, tmp_path: Path):
|
||||
import hermes_state
|
||||
|
||||
message = hermes_state._persistent_repair_exhausted_error(
|
||||
tmp_path / "state.db"
|
||||
)
|
||||
assert "Manual recovery required" in message
|
||||
assert "sessions recover --source" in message
|
||||
assert "--inspect-only" in message
|
||||
assert "--output recovered-state.db" in message
|
||||
# The old shape embedded the live path straight into a raw sqlite3
|
||||
# command: `sqlite3 {db_path} ".recover"`.
|
||||
assert ".recover\"`" not in message
|
||||
assert "do NOT" in message
|
||||
|
||||
def test_forensic_backup_refusals_name_safe_lane(self):
|
||||
"""The low-disk and stat-failure forensic backup refusal strings
|
||||
must not embed a raw sqlite3 command against the live path."""
|
||||
import hermes_state
|
||||
|
||||
body = inspect.getsource(hermes_state._backup_db_file)
|
||||
assert ".recover\"`" not in body
|
||||
assert "sessions recover --source" in body
|
||||
assert "--inspect-only" in body
|
||||
|
||||
def test_kanban_manual_recovery_warns_about_live_db(self):
|
||||
import hermes_cli.kanban as kanban
|
||||
|
||||
source = inspect.getsource(kanban)
|
||||
assert '`sqlite3 kanban.db ".recover"`' not in source
|
||||
assert "copy kanban.db aside FIRST" in source
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The emitted command satisfies the real CLI contract
|
||||
# ---------------------------------------------------------------------------
|
||||
# The reviewer's blocker on the first iteration of this fix: the banners
|
||||
# printed `hermes sessions recover --source <db>` — which cmd_sessions
|
||||
# rejects with exit 2 ("--output is required unless --inspect-only is
|
||||
# used") before any snapshot is taken. These tests dispatch the EXACT argv
|
||||
# shapes the banners emit through the real parser + cmd_sessions, so a
|
||||
# guidance string can never again pass a source-substring test while the
|
||||
# command it prints deterministically fails.
|
||||
|
||||
|
||||
class TestEmittedCommandsSatisfyCliContract:
|
||||
"""Every `sessions recover` argv the guidance prints must be accepted
|
||||
by the real CLI contract — the reviewer's blocker on the first
|
||||
iteration of this fix was exactly this: the banners printed
|
||||
`hermes sessions recover --source <db>`, which cmd_sessions rejects
|
||||
with exit 2 ("--output is required unless --inspect-only is used")
|
||||
before any snapshot is taken.
|
||||
|
||||
These tests dispatch the EXACT argv shapes the banners emit through
|
||||
the real `cmd_sessions` (the same function `hermes` main() hands the
|
||||
parsed namespace to), so a guidance string can never again pass a
|
||||
source-substring test while the command it prints deterministically
|
||||
fails.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _namespace(source: Path, **overrides) -> "argparse.Namespace":
|
||||
"""The namespace hermes main() produces for `sessions recover`.
|
||||
|
||||
Mirrors the registrations in hermes_cli/main.py (sessions_recover
|
||||
subparser): --source, --output, --inspect-only, --work-dir,
|
||||
--chunk-size (default 1000), --allow-partial, --report.
|
||||
"""
|
||||
fields = dict(
|
||||
sessions_action="recover",
|
||||
source=source,
|
||||
output=None,
|
||||
inspect_only=False,
|
||||
work_dir=None,
|
||||
chunk_size=1000,
|
||||
allow_partial=False,
|
||||
report=None,
|
||||
)
|
||||
fields.update(overrides)
|
||||
return argparse.Namespace(**fields)
|
||||
|
||||
def test_old_v1_shape_is_still_rejected(self, tmp_path):
|
||||
"""Guard the test's own premise: the bare `--source <db>` shape the
|
||||
v1 banner printed (neither --inspect-only nor --output) is rejected
|
||||
with rc 2 by the real dispatcher."""
|
||||
import hermes_cli.sessions_cmd as sc
|
||||
|
||||
rc = sc.cmd_sessions(self._namespace(tmp_path / "state.db"))
|
||||
assert rc == 2
|
||||
|
||||
def test_inspect_stage_dispatches_past_gate(self, tmp_path):
|
||||
"""`--inspect-only` (stage 1 of the emitted sequence) must pass
|
||||
the contract gate and reach actual inspection work (rc 0/1, not
|
||||
the gate's 2)."""
|
||||
import hermes_cli.sessions_cmd as sc
|
||||
|
||||
source = tmp_path / "state.db"
|
||||
conn = sqlite3.connect(str(source))
|
||||
try:
|
||||
conn.execute("CREATE TABLE t (x)")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
rc = sc.cmd_sessions(
|
||||
self._namespace(source, inspect_only=True)
|
||||
)
|
||||
assert rc != 2, "--inspect-only shape must pass the contract gate"
|
||||
|
||||
def test_output_stage_dispatches_past_gate(self, tmp_path):
|
||||
"""`--output recovered-state.db` (stage 2) must pass the contract
|
||||
gate and reach actual recovery work (rc 0/1, not the gate's 2)."""
|
||||
import hermes_cli.sessions_cmd as sc
|
||||
|
||||
source = tmp_path / "state.db"
|
||||
conn = sqlite3.connect(str(source))
|
||||
try:
|
||||
conn.execute("CREATE TABLE t (x)")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
rc = sc.cmd_sessions(
|
||||
self._namespace(source, output=tmp_path / "recovered-state.db")
|
||||
)
|
||||
assert rc != 2, "--output shape must pass the contract gate"
|
||||
|
||||
def test_banner_strings_emit_only_contract_valid_argv(self, tmp_path):
|
||||
"""The exact argv shapes embedded in the guidance strings, when
|
||||
parsed and dispatched, must never return the contract-gate 2.
|
||||
|
||||
Extracts each `sessions recover` invocation printed by the
|
||||
banners' code and runs its flag set through the real dispatcher.
|
||||
"""
|
||||
import hermes_cli.sessions_cmd as sc
|
||||
|
||||
source = tmp_path / "state.db"
|
||||
conn = sqlite3.connect(str(source))
|
||||
try:
|
||||
conn.execute("CREATE TABLE t (x)")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# Every emitted flag-set from the five guidance sites. Stage 1
|
||||
# (inspect) and stage 2 (output) as printed by the banners:
|
||||
emitted_shapes = [
|
||||
{"inspect_only": True}, # --inspect-only
|
||||
{"output": tmp_path / "recovered-state.db"}, # --output <new>
|
||||
]
|
||||
for overrides in emitted_shapes:
|
||||
rc = sc.cmd_sessions(self._namespace(source, **overrides))
|
||||
assert rc != 2, (
|
||||
f"emitted shape {overrides} must pass the cmd_sessions "
|
||||
"contract gate — the banner is printing a command the CLI "
|
||||
"rejects before doing anything"
|
||||
)
|
||||
Reference in New Issue
Block a user