From 396a8b1c003de4ccc8e7f779cb595437f50696b7 Mon Sep 17 00:00:00 2001 From: Cole Date: Tue, 26 May 2026 13:58:55 -0400 Subject: [PATCH] fix: harden governed learning storage --- README.md | 6 +- README.zh-CN.md | 6 +- docs/PRODUCTION-STATUS.md | 12 +- docs/v0.14.1-announcement.md | 42 +++ pyproject.toml | 2 +- src/agent_mem_bridge/learning_candidates.py | 3 +- src/agent_mem_bridge/promotion.py | 11 +- src/agent_mem_bridge/query.py | 3 +- src/agent_mem_bridge/repository.py | 14 +- src/agent_mem_bridge/schema.py | 72 ++++- src/agent_mem_bridge/server.py | 20 +- src/agent_mem_bridge/storage.py | 37 ++- tests/test_v0141_hardening.py | 329 ++++++++++++++++++++ 13 files changed, 511 insertions(+), 46 deletions(-) create mode 100644 docs/v0.14.1-announcement.md create mode 100644 tests/test_v0141_hardening.py diff --git a/README.md b/README.md index 5e865f5..305dd7b 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ AMB takes a smaller path: local SQLite, explicit namespaces, inspectable records - Coordination signals: `claim -> extend -> ack / expire / reclaim` without pretending to be a scheduler. - Governed learning: runtime learning can be staged as policy-gated learning candidates before promotion into durable records. - Context assembly: startup and task-time context can be compiled from procedures, concepts, beliefs, gotchas, and linked support without adding more MCP tools. -- Proof discipline: release contract checks, public-surface checks, onboarding checks, benchmark snapshots, and `228 passed`. +- Proof discipline: release contract checks, public-surface checks, onboarding checks, benchmark snapshots, and `243 passed`. ## Who It Is For @@ -156,7 +156,7 @@ Some MCP clients generate one static input schema per tool and may send signal-o ## Proof Snapshot -`0.14.0` is the governed learning-candidate gate release while keeping the public tool surface stable. +`0.14.1` is the governed learning-candidate hardening release while keeping the public tool surface stable. | Track | Current signal | |---|---| @@ -166,7 +166,7 @@ Some MCP clients generate one static input schema per tool and may send signal-o | Learning candidates | policy-gated staging records are suppressed from normal recall, browse, export, and stats until explicitly reviewed | | Signal contention | `signal_contention_case_pass_rate = 1.0`, `duplicate_active_claim_count = 0` | | Adversarial memory governance | `adversarial_case_count = 6`, `adversarial_task_count = 7`, `adversarial_governed_task_pass_rate = 1.0`, `adversarial_governed_blocked_record_leak_rate = 0.0` | -| Test suite | `228 passed` | +| Test suite | `243 passed` |
Release contract facts diff --git a/README.zh-CN.md b/README.zh-CN.md index 194ea83..2d0c9f3 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -36,7 +36,7 @@ AMB 选择更小的路径:本地 SQLite、显式 namespace、可检查记录 - Coordination signals:`claim -> extend -> ack / expire / reclaim`,但不假装自己是 scheduler。 - Governed learning:runtime learning 可以先进入 policy-gated learning candidate staging,再经过 review/promotion 变成 durable records。 - Context assembly:startup 和 task-time context 可以从 procedure、concept、belief、gotcha 和 linked support 编译出来,不需要增加更多 MCP tools。 -- Proof discipline:release contract、public-surface check、onboarding check、benchmark snapshot,以及 `228 passed`。 +- Proof discipline:release contract、public-surface check、onboarding check、benchmark snapshot,以及 `243 passed`。 ## 适合谁 @@ -156,7 +156,7 @@ bridge 暴露 `10` public MCP tools: ## Proof Snapshot -`0.14.0` 是 governed learning-candidate gate release,同时保持 public tool surface 稳定。 +`0.14.1` 是 governed learning-candidate hardening release,同时保持 public tool surface 稳定。 | Track | Current signal | |---|---| @@ -166,7 +166,7 @@ bridge 暴露 `10` public MCP tools: | Learning candidates | policy-gated staging records 默认不进入 normal recall、browse、export 和 stats,只有显式 review tag 才会出现 | | Signal contention | `signal_contention_case_pass_rate = 1.0`, `duplicate_active_claim_count = 0` | | Adversarial memory governance | `adversarial_case_count = 6`, `adversarial_task_count = 7`, `adversarial_governed_task_pass_rate = 1.0`, `adversarial_governed_blocked_record_leak_rate = 0.0` | -| Test suite | `228 passed` | +| Test suite | `243 passed` |
Release contract facts diff --git a/docs/PRODUCTION-STATUS.md b/docs/PRODUCTION-STATUS.md index de8bd13..4512e9e 100644 --- a/docs/PRODUCTION-STATUS.md +++ b/docs/PRODUCTION-STATUS.md @@ -2,9 +2,9 @@ Last updated: 2026-05-26 (America/Toronto) -This maintainer note describes the released `0.14.0` governed learning-candidate shape plus the validation snapshot used to support it. +This maintainer note describes the released `0.14.1` governed learning-candidate hardening shape plus the validation snapshot used to support it. -## Released 0.14.0 Runtime Shape +## Released 0.14.1 Runtime Shape `agent-memory-bridge` now has these cooperating layers: @@ -23,7 +23,7 @@ This maintainer note describes the released `0.14.0` governed learning-candidate ## Verified On 2026-05-26 -- `pytest` passes: `228 passed` +- `pytest` passes: `243 passed` - targeted learning-candidate tests cover policy decisions, hidden review records, forged-decision rejection, and public-surface stability - deterministic proof reports `4/4` checks passed - deterministic proof and benchmark both report `relation_metadata_passed = true` @@ -79,7 +79,7 @@ This maintainer note describes the released `0.14.0` governed learning-candidate - the CLI can now render config snippets for generic stdio MCP, Codex, Cursor, Cline, Claude Code, Claude Desktop, and Antigravity - `doctor` and `verify` provide local install confidence without touching live bridge state -## What 0.14.0 Actually Means +## What 0.14.1 Actually Means - the public MCP surface is still the same small bridge - runtime learning can be proposed as a policy-gated candidate instead of becoming ordinary durable memory immediately @@ -102,7 +102,7 @@ The release still does **not** mean: - exactly-once distributed coordination - that every MCP client is fully verified just because the generic stdio contract is stable -## Pressure Points After 0.14.0 +## Pressure Points After 0.14.1 The most important remaining gaps are: @@ -116,7 +116,7 @@ The most important remaining gaps are: ## Maintainer Read -`0.14.0` keeps the public MCP surface small while adding the missing staging layer between runtime learning and durable AMB memory. The project now reads as a general MCP memory product with local proof for memory, task assembly, procedure governance, onboarding, signal ownership, and governed learning writeback. +`0.14.1` keeps the public MCP surface small while hardening the 0.14 governed-learning boundary between runtime learning and durable AMB memory. The project now reads as a general MCP memory product with local proof for memory, task assembly, procedure governance, onboarding, signal ownership, and governed learning writeback. It now behaves like: diff --git a/docs/v0.14.1-announcement.md b/docs/v0.14.1-announcement.md new file mode 100644 index 0000000..7f86f2c --- /dev/null +++ b/docs/v0.14.1-announcement.md @@ -0,0 +1,42 @@ +# v0.14.1 - Governance Hardening + +Agent Memory Bridge 0.14.1 hardens the 0.14 governed learning-candidate release after post-release review found schema, storage, and governance edge cases. + +## Thesis + +`0.14.1 = the 0.14 learning-candidate boundary made safer under static MCP schemas, concurrent SQLite startup, and promotion/review bypass attempts.` + +## What Changed + +- Normalized empty optional text fields from static-schema MCP clients at the server and storage boundaries. +- Allowed durable `kind="memory"` writes to tolerate placeholder empty `expires_at` while keeping direct `ttl_seconds` misuse strict. +- Normalized empty `claim_signal` filters so placeholder `signal_id`, `tags_any`, or `correlation_id` values do not accidentally block eligible pending signals. +- Hardened schema migrations against SQL identifier injection by validating table and column identifiers before `PRAGMA table_info(...)`. +- Made `ensure_column(...)` tolerate duplicate-column races from concurrent migration attempts. +- Added SQLite `PRAGMA busy_timeout=5000` on bridge connections. +- Wrapped FTS table rebuild in a savepoint and added a concurrency smoke regression. +- Added an indexed `is_learning_candidate` visibility column and legacy backfill so learning-candidate suppression is exact and no longer depends on broad string scans. +- Blocked direct `promote(...)` on learning-candidate records and conservative forged `record_type: learning-candidate` content. +- Added MCP-boundary regressions for static-schema `store`, `claim_signal`, `recall`, and `export` behavior. + +## Evidence + +Current release snapshot: + +- `pytest`: `243 passed` +- targeted hardening subset: 59 targeted checks passed in `tests/test_v0141_hardening.py`, `tests/test_storage.py`, and `tests/test_learning_candidates.py` +- release contract: passed +- public surface contract: passed +- onboarding contract: passed +- `pip check`: passed +- public MCP tools: `10` + +## Boundaries + +The public MCP surface remains unchanged at `10` tools: + +- `store`, `recall`, `browse`, `stats` +- `forget`, `promote`, `export` +- `claim_signal`, `extend_signal_lease`, `ack_signal` + +This release does not turn AMB into a scheduler, distributed lock service, hosted backend, or automatic durable writeback path. The FTS startup regression is a smoke-level concurrency check, not a proof of all multi-process lock interleavings. diff --git a/pyproject.toml b/pyproject.toml index 75c12d7..13f6b4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "agent-memory-bridge" -version = "0.14.0" +version = "0.14.1" description = "Persistent engineering memory for coding agents over MCP" readme = "README.md" license = "MIT" diff --git a/src/agent_mem_bridge/learning_candidates.py b/src/agent_mem_bridge/learning_candidates.py index 164b086..1d6874c 100644 --- a/src/agent_mem_bridge/learning_candidates.py +++ b/src/agent_mem_bridge/learning_candidates.py @@ -5,6 +5,7 @@ from typing import Any from .learning_policy import evaluate_learning_candidate +from .repository import LEARNING_CANDIDATE_TAG VALID_CANDIDATE_STATUSES = {"pending", "needs_review", "approved", "rejected", "expired"} STORABLE_DECISIONS = {"allow", "needs_review"} @@ -54,7 +55,7 @@ def store_learning_candidate( title_claim = truncate(claim or candidate_ref, limit=72) content = build_learning_candidate_record(candidate, decision, candidate_status=status) tags = [ - "kind:learning-candidate", + LEARNING_CANDIDATE_TAG, f"candidate_status:{status}", f"source_runtime:{source_runtime}", f"authority_class:{authority_class}", diff --git a/src/agent_mem_bridge/promotion.py b/src/agent_mem_bridge/promotion.py index 1ab3dd3..20c4952 100644 --- a/src/agent_mem_bridge/promotion.py +++ b/src/agent_mem_bridge/promotion.py @@ -4,7 +4,7 @@ import json from typing import Any -from .repository import MemoryRow, fetch_row_by_id, merge_tags, normalize_content +from .repository import LEARNING_CANDIDATE_TAG, MemoryRow, fetch_row_by_id, merge_tags, normalize_content PROMOTABLE_RECORD_TYPES = {"learn", "gotcha", "domain-note"} @@ -26,6 +26,8 @@ def promote_entry(store: Any, memory_id: str, to_kind: str) -> dict[str, Any]: raise ValueError("only kind=memory entries can be promoted") source = MemoryRow.from_sqlite(row) + if is_learning_candidate_record(source): + raise ValueError("learning candidates cannot be promoted directly; review and store durable memory explicitly") current_record_type = record_type_for_row(source) if current_record_type == target_kind: store._log("promote", {"id": cleaned_id, "changed": False, "reason": "already-target-kind"}) @@ -71,7 +73,7 @@ def promote_entry(store: Any, memory_id: str, to_kind: str) -> dict[str, Any]: conn.execute( """ UPDATE memories - SET title = ?, content = ?, tags_json = ?, content_hash = ? + SET title = ?, content = ?, tags_json = ?, is_learning_candidate = 0, content_hash = ? WHERE id = ? """, ( @@ -149,6 +151,11 @@ def record_type_for_row(row: MemoryRow) -> str: return parse_structured_record(row.content).get("record_type", "memory") +def is_learning_candidate_record(row: MemoryRow) -> bool: + fields = parse_structured_record(row.content) + return LEARNING_CANDIDATE_TAG in row.tags or fields.get("record_type") == "learning-candidate" + + def build_promoted_item(row: MemoryRow, *, target_kind: str, current_record_type: str) -> dict[str, Any]: fields = parse_structured_record(row.content) claim = fields.get("claim") or row.title or row.content diff --git a/src/agent_mem_bridge/query.py b/src/agent_mem_bridge/query.py index a285034..ff3326e 100644 --- a/src/agent_mem_bridge/query.py +++ b/src/agent_mem_bridge/query.py @@ -221,8 +221,7 @@ def build_filters( include_learning_candidates = should_include_learning_candidates(tags_any) if not include_learning_candidates: - clauses.append(f"{prefix}tags_json NOT LIKE ? ESCAPE '\\'") - params.append('%"kind:learning-candidate"%') + clauses.append(f"COALESCE({prefix}is_learning_candidate, 0) = 0") if kind is not None: clauses.append(f"{prefix}kind = ?") diff --git a/src/agent_mem_bridge/repository.py b/src/agent_mem_bridge/repository.py index 9a27e5f..58eb5dc 100644 --- a/src/agent_mem_bridge/repository.py +++ b/src/agent_mem_bridge/repository.py @@ -14,6 +14,7 @@ HASHTAG_RE = re.compile(r"(? "MemoryRow": lease_expires_at=row["lease_expires_at"], expires_at=row["expires_at"], acknowledged_at=row["acknowledged_at"], + is_learning_candidate=bool(row["is_learning_candidate"]), created_at=row["created_at"], ) @@ -117,6 +121,7 @@ def as_dict(self) -> dict[str, Any]: "lease_expires_at": self.lease_expires_at, "expires_at": self.expires_at, "acknowledged_at": self.acknowledged_at, + "is_learning_candidate": self.is_learning_candidate, "created_at": self.created_at, "relations": relation_metadata["relations"], "valid_from": relation_metadata["valid_from"], @@ -172,6 +177,7 @@ def store_entry( normalized_content = normalize_content(cleaned_content) payload_tags = merge_tags(tags, title=title, content=cleaned_content) + is_learning_candidate = int(LEARNING_CANDIDATE_TAG in payload_tags) content_hash = hashlib.sha256(normalized_content.encode("utf-8")).hexdigest() resolved_expires_at = resolve_signal_expiry(expires_at=expires_at, ttl_seconds=ttl_seconds) signal_status = "pending" if cleaned_kind == "signal" else None @@ -233,9 +239,10 @@ def store_entry( lease_expires_at, expires_at, acknowledged_at, + is_learning_candidate, content_hash, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( memory_id, @@ -259,6 +266,7 @@ def store_entry( None, resolved_expires_at, None, + is_learning_candidate, content_hash, created_at, ), @@ -355,10 +363,10 @@ def stats_for_namespace(store: Any, namespace: str) -> dict[str, Any]: {MEMORY_ROW_SELECT} FROM memories WHERE namespace = ? - AND tags_json NOT LIKE ? ESCAPE '\\' + AND is_learning_candidate = 0 ORDER BY created_at ASC """, - (cleaned_namespace, '%"kind:learning-candidate"%'), + (cleaned_namespace,), ).fetchall() kind_counts = {kind: 0 for kind in sorted(ALLOWED_KINDS)} diff --git a/src/agent_mem_bridge/schema.py b/src/agent_mem_bridge/schema.py index b7da14f..2c273bd 100644 --- a/src/agent_mem_bridge/schema.py +++ b/src/agent_mem_bridge/schema.py @@ -1,7 +1,16 @@ from __future__ import annotations +import re import sqlite3 +IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +def quote_identifier(identifier: str) -> str: + if not IDENTIFIER_RE.fullmatch(identifier): + raise ValueError(f"invalid SQL identifier: {identifier!r}") + return f'"{identifier}"' + def init_db(conn: sqlite3.Connection) -> None: conn.executescript( @@ -28,6 +37,7 @@ def init_db(conn: sqlite3.Connection) -> None: lease_expires_at TEXT, expires_at TEXT, acknowledged_at TEXT, + is_learning_candidate INTEGER NOT NULL DEFAULT 0, content_hash TEXT NOT NULL, created_at TEXT NOT NULL ); @@ -47,6 +57,17 @@ def init_db(conn: sqlite3.Connection) -> None: ensure_column(conn, "memories", "lease_expires_at", "ALTER TABLE memories ADD COLUMN lease_expires_at TEXT") ensure_column(conn, "memories", "expires_at", "ALTER TABLE memories ADD COLUMN expires_at TEXT") ensure_column(conn, "memories", "acknowledged_at", "ALTER TABLE memories ADD COLUMN acknowledged_at TEXT") + ensure_column(conn, "memories", "is_learning_candidate", "ALTER TABLE memories ADD COLUMN is_learning_candidate INTEGER NOT NULL DEFAULT 0") + conn.execute( + """ + UPDATE memories + SET is_learning_candidate = 1 + WHERE is_learning_candidate = 0 + AND tags_json LIKE '%"kind:learning-candidate"%' + AND EXISTS (SELECT 1 FROM json_each(memories.tags_json) WHERE value = 'kind:learning-candidate') + """ + ) + ensure_fts_columns(conn) conn.executescript( """ CREATE UNIQUE INDEX IF NOT EXISTS idx_memories_dedup @@ -79,16 +100,26 @@ def init_db(conn: sqlite3.Connection) -> None: CREATE INDEX IF NOT EXISTS idx_memories_signal_claimed_by_created_at ON memories (claimed_by, created_at DESC); + + CREATE INDEX IF NOT EXISTS idx_memories_learning_candidate_visible + ON memories (namespace, is_learning_candidate, created_at DESC); """ ) - ensure_fts_columns(conn) conn.commit() def ensure_column(conn: sqlite3.Connection, table: str, column: str, ddl: str) -> None: - columns = {row["name"] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()} - if column not in columns: + table_sql = quote_identifier(table) + quote_identifier(column) + columns = {row["name"] for row in conn.execute(f"PRAGMA table_info({table_sql})").fetchall()} + if column in columns: + return + try: conn.execute(ddl) + except sqlite3.OperationalError as exc: + if "duplicate column name" in str(exc).lower(): + return + raise def ensure_fts_columns(conn: sqlite3.Connection) -> None: @@ -96,17 +127,24 @@ def ensure_fts_columns(conn: sqlite3.Connection) -> None: if "title" in columns: return - existing_rows = conn.execute( - """ - SELECT id, COALESCE(title, '') AS title, content - FROM memories - ORDER BY created_at ASC - """ - ).fetchall() - conn.execute("DROP TABLE IF EXISTS memories_fts") - conn.execute("CREATE VIRTUAL TABLE memories_fts USING fts5(memory_id UNINDEXED, title, content)") - for row in existing_rows: - conn.execute( - "INSERT INTO memories_fts(memory_id, title, content) VALUES (?, ?, ?)", - (row["id"], row["title"], row["content"]), - ) + conn.execute("SAVEPOINT ensure_fts_columns") + try: + existing_rows = conn.execute( + """ + SELECT id, COALESCE(title, '') AS title, content + FROM memories + ORDER BY created_at ASC + """ + ).fetchall() + conn.execute("DROP TABLE IF EXISTS memories_fts") + conn.execute("CREATE VIRTUAL TABLE memories_fts USING fts5(memory_id UNINDEXED, title, content)") + for row in existing_rows: + conn.execute( + "INSERT INTO memories_fts(memory_id, title, content) VALUES (?, ?, ?)", + (row["id"], row["title"], row["content"]), + ) + conn.execute("RELEASE ensure_fts_columns") + except Exception: + conn.execute("ROLLBACK TO ensure_fts_columns") + conn.execute("RELEASE ensure_fts_columns") + raise diff --git a/src/agent_mem_bridge/server.py b/src/agent_mem_bridge/server.py index 0bae0b2..1775a23 100644 --- a/src/agent_mem_bridge/server.py +++ b/src/agent_mem_bridge/server.py @@ -184,11 +184,17 @@ def store( Returns the stored entry identifier, timestamp, and duplicate information. Repeated `memory` writes may deduplicate; `signal` writes are intended to remain append-like. """ - source_client = source_client or resolve_default_source_client() - source_model = source_model or resolve_default_source_model() - client_session_id = client_session_id or resolve_default_client_session_id() - client_workspace = client_workspace or resolve_default_client_workspace() - client_transport = client_transport or resolve_default_client_transport() + session_id = _optional_text(session_id) + actor = _optional_text(actor) + title = _optional_text(title) + correlation_id = _optional_text(correlation_id) + source_app = _optional_text(source_app) + source_client = _optional_text(source_client) or resolve_default_source_client() + source_model = _optional_text(source_model) or resolve_default_source_model() + client_session_id = _optional_text(client_session_id) or resolve_default_client_session_id() + client_workspace = _optional_text(client_workspace) or resolve_default_client_workspace() + client_transport = _optional_text(client_transport) or resolve_default_client_transport() + expires_at = _optional_text(expires_at) # MCP clients expose one static schema for both durable memory and expiring # signals. Some clients still send signal-only fields with placeholder values @@ -500,9 +506,9 @@ def claim_signal( namespace=namespace, consumer=consumer, lease_seconds=lease_seconds, - signal_id=signal_id, + signal_id=_optional_text(signal_id), tags_any=tags_any, - correlation_id=correlation_id, + correlation_id=_optional_text(correlation_id), ) diff --git a/src/agent_mem_bridge/storage.py b/src/agent_mem_bridge/storage.py index 21b784d..37cc13b 100644 --- a/src/agent_mem_bridge/storage.py +++ b/src/agent_mem_bridge/storage.py @@ -27,6 +27,20 @@ from .telemetry import Telemetry, hash_label +def _optional_text(value: str | None) -> str | None: + if value is None: + return None + cleaned = str(value).strip() + return cleaned or None + + +def _optional_list(value: list[str] | None) -> list[str] | None: + if value is None: + return None + cleaned = [str(item).strip() for item in value if str(item).strip()] + return cleaned or None + + class MemoryStore: def __init__(self, db_path: Path, log_dir: Path | None = None, telemetry: Telemetry | None = None) -> None: self.db_path = Path(db_path) @@ -63,6 +77,23 @@ def store( expires_at: str | None = None, ttl_seconds: int | None = None, ) -> dict[str, Any]: + kind = kind.strip() + tags = _optional_list(tags) + session_id = _optional_text(session_id) + actor = _optional_text(actor) + title = _optional_text(title) + correlation_id = _optional_text(correlation_id) + source_app = _optional_text(source_app) + source_client = _optional_text(source_client) + source_model = _optional_text(source_model) + client_session_id = _optional_text(client_session_id) + client_workspace = _optional_text(client_workspace) + client_transport = _optional_text(client_transport) + expires_at = _optional_text(expires_at) + if kind == "memory": + if ttl_seconds is not None: + raise ValueError("expires_at and ttl_seconds are only valid for kind='signal'") + expires_at = None relation_metadata = parse_relation_metadata(content) with self.telemetry.span( "amb.store.write", @@ -280,6 +311,9 @@ def claim_signal( tags_any: list[str] | None = None, correlation_id: str | None = None, ) -> dict[str, Any]: + signal_id = _optional_text(signal_id) + tags_any = _optional_list(tags_any) + correlation_id = _optional_text(correlation_id) with self.telemetry.span( "amb.signal.claim", { @@ -479,8 +513,9 @@ def recall_memory(self, **kwargs: Any) -> dict[str, Any]: return self.recall(**kwargs) def _connect(self) -> sqlite3.Connection: - conn = sqlite3.connect(self.db_path) + conn = sqlite3.connect(self.db_path, timeout=5.0) conn.row_factory = sqlite3.Row + conn.execute("PRAGMA busy_timeout=5000") conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA foreign_keys=ON") return conn diff --git a/tests/test_v0141_hardening.py b/tests/test_v0141_hardening.py new file mode 100644 index 0000000..565df43 --- /dev/null +++ b/tests/test_v0141_hardening.py @@ -0,0 +1,329 @@ +from __future__ import annotations + +import sqlite3 +from pathlib import Path +from threading import Thread + +import pytest + +from agent_mem_bridge import server +from agent_mem_bridge.learning_policy import evaluate_learning_candidate +from agent_mem_bridge.schema import ensure_column, init_db +from agent_mem_bridge.storage import MemoryStore + + +def _candidate(**overrides): + candidate = { + "schema": "memory.candidate.v1", + "namespace": "project:mem-store", + "authority_class": "context_hint", + "claim": "AMB hardening must prevent schema and learning-candidate bypasses.", + "evidence_refs": ["pytest: tests/test_v0141_hardening.py"], + "source_runtime": "hermes", + "source_session_id": "session-1", + "source_task_id": "task-1", + "sensitivity": "safe", + "created_by": "cole", + } + candidate.update(overrides) + return candidate + + +def test_store_normalizes_static_schema_empty_optional_fields(tmp_path: Path) -> None: + store = MemoryStore(tmp_path / "bridge.db", log_dir=tmp_path / "logs") + + created = store.store( + namespace=" project:mem-store ", + content="Static-schema clients may send empty strings for absent optional fields.", + kind="memory", + tags=[], + session_id="", + actor="", + correlation_id="", + source_app="", + source_client="", + source_model="", + client_session_id="", + client_workspace="", + client_transport="", + expires_at="", + ) + item = store.recall(namespace="project:mem-store", query="static schema", limit=1)["items"][0] + + assert created["stored"] is True + assert item["session_id"] is None + assert item["actor"] is None + assert item["correlation_id"] is None + assert item["source_app"] is None + assert item["source_client"] is None + assert item["source_model"] is None + assert item["client_session_id"] is None + assert item["client_workspace"] is None + assert item["client_transport"] is None + assert item["signal_status"] is None + + +def test_server_store_normalizes_static_schema_empty_optional_fields(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + store = MemoryStore(tmp_path / "server.db", log_dir=tmp_path / "logs") + monkeypatch.setattr(server, "bridge", store) + + created = server.store( + namespace="project:mem-store", + content="MCP static schema sent empty optional placeholders.", + kind="memory", + tags=[], + session_id="", + actor="", + title="", + correlation_id="", + source_app="", + source_client="", + source_model="", + client_session_id="", + client_workspace="", + client_transport="", + expires_at="", + ttl_seconds=None, + ) + item = store.recall(namespace="project:mem-store", query="static schema", limit=1)["items"][0] + + assert created["stored"] is True + assert item["actor"] is None + assert item["session_id"] is None + assert item["correlation_id"] is None + + +def test_claim_signal_normalizes_empty_static_schema_filters(tmp_path: Path) -> None: + store = MemoryStore(tmp_path / "bridge.db", log_dir=tmp_path / "logs") + created = store.store( + namespace="project:bridge", + content="Claim filters with empty placeholders must behave as absent filters.", + kind="signal", + tags=["handoff:review"], + correlation_id="real-correlation", + ) + + claimed = store.claim_signal( + namespace="project:bridge", + consumer="worker-a", + lease_seconds=60, + signal_id="", + tags_any=[], + correlation_id="", + ) + + assert claimed["claimed"] is True + assert claimed["signal_id"] == created["id"] + + +def test_server_claim_signal_normalizes_empty_static_schema_filters(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + store = MemoryStore(tmp_path / "server.db", log_dir=tmp_path / "logs") + monkeypatch.setattr(server, "bridge", store) + created = server.store( + namespace="project:bridge", + content="Server claim filters with empty placeholders must behave as absent filters.", + kind="signal", + tags=["handoff:review"], + correlation_id="real-correlation", + ) + + claimed = server.claim_signal( + namespace="project:bridge", + consumer="worker-a", + lease_seconds=60, + signal_id="", + tags_any=[], + correlation_id="", + ) + + assert claimed["claimed"] is True + assert claimed["signal_id"] == created["id"] + + +def test_sqlite_connection_sets_busy_timeout(tmp_path: Path) -> None: + store = MemoryStore(tmp_path / "bridge.db", log_dir=tmp_path / "logs") + + with store._connect() as conn: + busy_timeout = conn.execute("PRAGMA busy_timeout").fetchone()[0] + + assert busy_timeout >= 5000 + + +def test_legacy_learning_candidate_rows_are_backfilled_hidden(tmp_path: Path) -> None: + db_path = tmp_path / "legacy.db" + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + conn.executescript( + """ + CREATE TABLE memories ( + id TEXT PRIMARY KEY, + namespace TEXT NOT NULL, + kind TEXT NOT NULL, + title TEXT, + content TEXT NOT NULL, + tags_json TEXT NOT NULL DEFAULT '[]', + session_id TEXT, + actor TEXT, + correlation_id TEXT, + source_app TEXT, + source_client TEXT, + source_model TEXT, + client_session_id TEXT, + client_workspace TEXT, + client_transport TEXT, + signal_status TEXT, + claimed_by TEXT, + claimed_at TEXT, + lease_expires_at TEXT, + expires_at TEXT, + acknowledged_at TEXT, + content_hash TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE VIRTUAL TABLE memories_fts USING fts5(memory_id UNINDEXED, content); + INSERT INTO memories (id, namespace, kind, title, content, tags_json, content_hash, created_at) + VALUES ('legacy-candidate', 'project:mem-store', 'memory', 'Legacy candidate', 'legacy candidate hardening row', '["kind:learning-candidate"]', 'hash-1', '2026-01-01T00:00:00+00:00'); + INSERT INTO memories_fts(memory_id, content) VALUES ('legacy-candidate', 'legacy candidate hardening row'); + """ + ) + conn.commit() + conn.close() + + with sqlite3.connect(db_path) as upgraded: + upgraded.row_factory = sqlite3.Row + init_db(upgraded) + row = upgraded.execute("SELECT is_learning_candidate FROM memories WHERE id = 'legacy-candidate'").fetchone() + + store = MemoryStore(db_path, log_dir=tmp_path / "logs") + + assert row["is_learning_candidate"] == 1 + assert store.recall(namespace="project:mem-store", query="legacy", limit=10)["count"] == 0 + assert store.recall(namespace="project:mem-store", tags_any=["kind:learning-candidate"], limit=10)["count"] == 1 + + +def test_new_schema_has_learning_candidate_visibility_column(tmp_path: Path) -> None: + store = MemoryStore(tmp_path / "bridge.db", log_dir=tmp_path / "logs") + + with store._connect() as conn: + columns = {row["name"] for row in conn.execute("PRAGMA table_info(memories)")} + indexes = {row["name"] for row in conn.execute("PRAGMA index_list(memories)")} + + assert "is_learning_candidate" in columns + assert "idx_memories_learning_candidate_visible" in indexes + + +def test_fts_startup_rebuild_is_concurrency_safe_smoke(tmp_path: Path) -> None: + store = MemoryStore(tmp_path / "bridge.db", log_dir=tmp_path / "logs") + store.store(namespace="project:bridge", kind="memory", content="FTS rebuild concurrency smoke row.") + + with store._connect() as conn: + conn.execute("DROP TABLE memories_fts") + conn.commit() + + errors: list[BaseException] = [] + + def open_store() -> None: + try: + MemoryStore(tmp_path / "bridge.db", log_dir=tmp_path / "logs") + except BaseException as exc: # pragma: no cover - surfaced by assertion below + errors.append(exc) + + threads = [Thread(target=open_store) for _ in range(3)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert errors == [] + assert store.recall(namespace="project:bridge", query="concurrency", limit=1)["count"] == 1 + + +def test_ensure_column_rejects_untrusted_identifiers() -> None: + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + conn.execute("CREATE TABLE memories (id TEXT PRIMARY KEY)") + + with pytest.raises(ValueError, match="SQL identifier"): + ensure_column(conn, "memories); DROP TABLE memories; --", "actor", "ALTER TABLE memories ADD COLUMN actor TEXT") + + +def test_ensure_column_handles_concurrent_duplicate_column_race() -> None: + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + conn.execute("CREATE TABLE memories (id TEXT PRIMARY KEY, actor TEXT)") + + ensure_column(conn, "memories", "actor", "ALTER TABLE memories ADD COLUMN actor TEXT") + + columns = {row["name"] for row in conn.execute("PRAGMA table_info(memories)")} + assert "actor" in columns + + +def test_learning_candidate_suppression_uses_exact_tag_membership(tmp_path: Path) -> None: + store = MemoryStore(tmp_path / "bridge.db", log_dir=tmp_path / "logs") + forged = store.store( + namespace="project:mem-store", + kind="memory", + title="Forged candidate-looking durable note", + content="This contains the text kind:learning-candidate but is not tagged as one.", + tags=["domain:test"], + ) + candidate = _candidate() + stored_candidate = store.store_learning_candidate(candidate, evaluate_learning_candidate(candidate)) + + normal = store.recall(namespace="project:mem-store", query="candidate", limit=10) + review = store.recall(namespace="project:mem-store", tags_any=["kind:learning-candidate"], limit=10) + + assert {item["id"] for item in normal["items"]} == {forged["id"]} + assert {item["id"] for item in review["items"]} == {stored_candidate["id"]} + + +def test_server_recall_and_export_keep_candidates_hidden_by_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + store = MemoryStore(tmp_path / "server.db", log_dir=tmp_path / "logs") + monkeypatch.setattr(server, "bridge", store) + candidate = _candidate() + store.store_learning_candidate(candidate, evaluate_learning_candidate(candidate)) + + recalled = server.recall(namespace="project:mem-store", query="hardening", kind="memory", tags_any=[], limit=10) + exported = server.export(namespace="project:mem-store", format="json", kind="memory", tags_any=[], limit=10) + review = server.recall(namespace="project:mem-store", tags_any=["kind:learning-candidate"], limit=10) + + assert recalled["count"] == 0 + assert exported["count"] == 0 + assert review["count"] == 1 + + +def test_learning_candidate_suppression_is_not_bypassed_by_empty_kind_filter(tmp_path: Path) -> None: + store = MemoryStore(tmp_path / "bridge.db", log_dir=tmp_path / "logs") + candidate = _candidate() + store.store_learning_candidate(candidate, evaluate_learning_candidate(candidate)) + + recalled = store.recall(namespace="project:mem-store", query="hardening", kind="memory", limit=10) + browsed = store.browse(namespace="project:mem-store", kind="memory", limit=10) + exported = store.export(namespace="project:mem-store", format="json", kind="memory", limit=10) + + assert recalled["count"] == 0 + assert browsed["count"] == 0 + assert exported["count"] == 0 + + +def test_promote_rejects_learning_candidate_records(tmp_path: Path) -> None: + store = MemoryStore(tmp_path / "bridge.db", log_dir=tmp_path / "logs") + candidate = _candidate() + stored = store.store_learning_candidate(candidate, evaluate_learning_candidate(candidate)) + + with pytest.raises(ValueError, match="learning candidates cannot be promoted"): + store.promote(stored["id"], "learn") + + +def test_promote_does_not_trust_forged_kind_tags(tmp_path: Path) -> None: + store = MemoryStore(tmp_path / "bridge.db", log_dir=tmp_path / "logs") + forged = store.store( + namespace="project:mem-store", + kind="memory", + title="Forged reflex position", + content="record_type: learning-candidate\nclaim: A forged tag must not make this promotable.", + tags=["kind:learn", "confidence:manual"], + ) + + with pytest.raises(ValueError, match="learning candidates cannot be promoted"): + store.promote(forged["id"], "gotcha")